Build a GraphQL Wrapper Over TikLiveAPI in TypeScript

Published on May 29, 2026

Most teams building products on top of TikLiveAPI hit the same wall around the third or fourth screen: the frontend is making five sequential REST calls to render one user profile (user info, posts, comment counts, music for each post, hashtag lookups), the round-trip count is killing perceived performance, and the TypeScript types are drifting out of sync with what the API actually returns. The fix is not "add more endpoints." It is to put a thin GraphQL layer in front of the REST API so the client asks for exactly the data it needs in one request, with one typed schema, and one well-defined error contract.

This guide walks through wrapping TikLiveAPI with an Apollo Server in TypeScript. We will design a schema rooted in User, Post, Comment, Hashtag, and Music, wire up resolvers that call the underlying REST endpoints, use DataLoader to kill N+1s, and end with a working ~100 line skeleton you can deploy today.

Why wrap a REST API with GraphQL at all

The case for a GraphQL wrapper is not about replacing REST. TikLiveAPI's 37 endpoints are already well-designed, focused, and stable. The wrapper exists to make the client side easier.

  • Typed schema as the contract. The schema becomes the single source of truth your frontend codegen consumes. User.stats.followerCount is a number, always, with documentation attached. No more guessing whether a counter is a string or a number after a JSON parse.
  • Single round-trip, multiple resources. Rendering a profile page that needs user info plus the latest 10 posts plus the hashtags inside each post used to be three or four sequential REST calls. With GraphQL it is one POST.
  • Frontend ergonomics. Components declare their own data needs co-located with the JSX. No central "API client" growing endpoint methods every sprint.
  • Query batching and persisted queries. Apollo Client batches concurrent queries into one HTTP request, and APQ lets you ship hashes instead of full query strings, cutting upstream bandwidth.

Trade-offs to acknowledge first

A GraphQL wrapper is not free. Be honest about the costs before you commit:

  • Added latency layer. Every request now hops through your gateway before hitting api.tikliveapi.com. With a co-located server you can keep the overhead under 5ms, but it is never zero.
  • Server complexity. You now own resolvers, a schema, a DataLoader cache, a deployment, and an observability story for a service that used to be "the browser talks to the API."
  • Caching is harder. REST has CDN-friendly GET URLs. GraphQL is POST by default, so HTTP caching needs persisted queries or response cache plugins.
  • N+1 risk. The same flexibility that lets a client request 50 posts with each author embedded can fan out to 50 sequential /userinfo-by-id/ calls if you do not batch.

If your product is a single dashboard page hitting two endpoints, skip GraphQL. If you are building a multi-surface product (web app, mobile, admin tools) all talking to TikLiveAPI, the wrapper pays for itself in a quarter.

Stack choice

Three serious options exist in 2026:

  • Node.js with Apollo Server 4. Mature ecosystem, first-class TypeScript, DataLoader was invented here, Apollo Client on the frontend means matching mental models. This is what we will use.
  • Python with Strawberry. Best choice if your team already runs Python services. Type hints become the schema, async resolvers are clean. Slightly less mature plugin ecosystem.
  • Go with gqlgen. Best raw performance and lowest memory footprint. Worth it if you are processing tens of millions of requests per day and the gateway is the bottleneck.

We will use Apollo Server because it gives the shortest path from "no code" to "production ready wrapper," and TypeScript across the stack means the schema, resolver signatures, and frontend hooks share types end to end.

Schema design from TikLiveAPI endpoints

The schema is the part you should spend the most time on. Map the REST shapes into idiomatic GraphQL types and resist the urge to expose every JSON field one-to-one. TikLiveAPI mixes camelCase (the user{}/stats{} objects from /userinfo-by-username/) and snake_case (post items from /post-detail/), so the wrapper is also where you normalize.

type User {
  id: ID!
  uniqueId: String!
  nickname: String!
  signature: String
  verified: Boolean!
  avatarLarger: String
  secUid: String
  privateAccount: Boolean
  stats: UserStats!
  posts(first: Int = 10, after: String): PostConnection!
  followers(first: Int = 50, after: String): UserConnection!
}

type UserStats {
  followerCount: Int!
  followingCount: Int!
  heartCount: Int!
  videoCount: Int!
  diggCount: Int!
}

type Post {
  id: ID!
  awemeId: String!
  videoId: String!
  region: String
  title: String
  cover: String
  play: String
  wmplay: String
  hdplay: String
  playCount: Int!
  diggCount: Int!
  commentCount: Int!
  shareCount: Int!
  createTime: Int!
  author: User!
  music: Music
  comments(first: Int = 20, after: String): CommentConnection!
}

type Comment {
  id: ID!
  text: String!
  diggCount: Int!
  replyTotal: Int!
  videoId: String!
  createTime: Int!
  user: User!
  replies(first: Int = 20, after: String): CommentConnection!
}

type Hashtag {
  id: ID!
  chaName: String!
  desc: String
  userCount: Int!
  viewCount: Int!
  cover: String
}

type Music {
  id: ID!
  title: String!
  play: String!
  cover: String
  author: String
  original: Boolean!
  duration: Int!
  videoCount: Int!
}

Three small but important calls were made above. First, the Post.id is the field you choose as canonical (we use aweme_id) while still exposing both awemeId and videoId because clients need both. Second, the hashtag field is named chaName to match what /challenge-info-name/ actually returns. Third, the Comment.id field maps to the id key in the comments response (not cid).

Query root and Relay-style pagination

The top-level entry points map directly to TikLiveAPI's lookup endpoints:

type Query {
  user(username: String!): User
  userById(id: ID!): User
  post(url: String!): Post
  hashtag(name: String!): Hashtag
  hashtagById(id: ID!): Hashtag
  music(id: ID!): Music
  searchUsers(keyword: String!, first: Int = 10, after: String): UserConnection!
  searchPosts(keyword: String!, first: Int = 10, after: String): PostConnection!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
}
type PostEdge { cursor: String!; node: Post! }
type PageInfo { hasNextPage: Boolean!; endCursor: String }

Relay-style connections map cleanly onto TikLiveAPI's cursor + hasMore envelope. The wrapper translates hasMore into pageInfo.hasNextPage and the returned cursor into pageInfo.endCursor. For /user-followers/ and /user-following/, which page on time instead of cursor, the resolver does the swap internally and still exposes the standard after argument upstream. The /user-following/ top-level key in the raw JSON is followings (plural with the trailing s) - one of those small details that wrapping fixes once at the boundary.

Resolver implementation

Resolvers do three things: call the REST endpoint, reshape the response into the GraphQL type, and pass partial data forward so child resolvers can fill in the rest. The pattern looks like this:

type Ctx = {
  apiKey: string;
  loaders: {
    userByUsername: DataLoader<string, RawUser>;
    userById: DataLoader<string, RawUser>;
  };
};

const resolvers = {
  Query: {
    user: async (_: unknown, { username }: { username: string }, ctx: Ctx) => {
      const raw = await ctx.loaders.userByUsername.load(username);
      return raw ? mapUser(raw) : null;
    },
  },

  User: {
    id: async (parent: MappedUser, _args, ctx: Ctx) => {
      if (parent.id) return parent.id;
      const r = await fetchJson(ctx, '/userid/', { username: parent.uniqueId });
      return r.id as string;
    },

    posts: async (parent: MappedUser, args: PageArgs, ctx: Ctx) => {
      const data = await fetchJson(ctx, '/user-posts/', {
        userid: parent.id,
        count: args.first ?? 10,
        cursor: args.after ?? '',
      });
      return toConnection(data.videos.map(mapPost), data.cursor, data.hasMore);
    },
  },

  Post: {
    comments: async (parent: MappedPost, args: PageArgs, ctx: Ctx) => {
      const data = await fetchJson(ctx, '/post-comments/', {
        url: parent.shareUrl,
        count: args.first ?? 20,
        cursor: args.after ?? '',
      });
      return toConnection(data.comments.map(mapComment), data.cursor, data.hasMore);
    },
  },
};

The User.id resolver is the interesting one. When a client queries user(username: "khaby.lame") { id posts { ... } }, the wrapper hits /userinfo-by-username/ first (which gives us user.id already), so the id resolver short-circuits. If a client only knew the username and wanted the id, the /userid/ fallback kicks in.

DataLoader to prevent N+1

The most common GraphQL footgun: a client requests searchPosts(keyword: "cats", first: 50) { node { author { nickname } } } and your resolvers fire 50 sequential /userinfo-by-id/ calls. DataLoader is the canonical fix - it batches and dedupes within a single request.

import DataLoader from 'dataloader';

function makeLoaders(apiKey: string) {
  return {
    userById: new DataLoader<string, RawUser>(async (ids) => {
      // TikLiveAPI has no batch endpoint, so we fan out in parallel
      // but DataLoader dedupes duplicate ids inside one request.
      const unique = [...new Set(ids)];
      const results = await Promise.all(
        unique.map((id) => fetchJson({ apiKey }, '/userinfo-by-id/', { userid: id }))
      );
      const byId = new Map(unique.map((id, i) => [id, results[i]]));
      return ids.map((id) => byId.get(id)!);
    }, { maxBatchSize: 25 }),

    userByUsername: new DataLoader<string, RawUser>(async (usernames) => {
      const unique = [...new Set(usernames)];
      const results = await Promise.all(
        unique.map((u) => fetchJson({ apiKey }, '/userinfo-by-username/', { username: u }))
      );
      const byName = new Map(unique.map((u, i) => [u, results[i]]));
      return usernames.map((u) => byName.get(u)!);
    }),
  };
}

Even without a true batch endpoint upstream, DataLoader earns its keep through deduplication. If three different posts in a search result happen to share an author, you make one upstream call instead of three. The loaders are created per request inside the context factory so caching is request-scoped, not global - critical for a credit-billed API where you do not want to serve hour-old data.

Error handling: GraphQL errors vs partial responses

GraphQL's killer feature for an API wrapper is partial responses. If a client asks for a user plus that user's last 30 posts plus the music for each post and the music endpoint times out for two posts, you can still return everything else with music: null on the affected entries and an errors array describing what broke.

import { GraphQLError } from 'graphql';

async function fetchJson(ctx: Ctx, path: string, params: Record<string, string | number>) {
  const url = new URL(path, 'https://api.tikliveapi.com');
  Object.entries(params).forEach(([k, v]) => v !== '' && url.searchParams.set(k, String(v)));
  const res = await fetch(url, { headers: { 'X-Api-Key': ctx.apiKey } });
  if (res.status === 401) {
    throw new GraphQLError('Invalid API key', { extensions: { code: 'UNAUTHENTICATED' } });
  }
  if (res.status === 429) {
    throw new GraphQLError('Out of credits', { extensions: { code: 'INSUFFICIENT_CREDITS' } });
  }
  if (!res.ok) {
    throw new GraphQLError(`Upstream ${res.status}`, { extensions: { code: 'UPSTREAM_ERROR', path } });
  }
  return res.json();
}

The extensions.code field is what frontend code keys off. UNAUTHENTICATED redirects to login, INSUFFICIENT_CREDITS opens the pricing modal, UPSTREAM_ERROR shows a retry button. Do not leak raw upstream error messages to the client.

Caching: APQ and response caching

Two cache layers matter for a TikLiveAPI wrapper:

  • Automatic Persisted Queries. Apollo Client sends a SHA-256 hash of the query first; the server only requires the full query string on first sight. After warmup, your POST bodies shrink to a few hundred bytes regardless of how complex the query is.
  • Response cache plugin. The @apollo/server-plugin-response-cache package keys responses by query hash + variables and respects per-field @cacheControl(maxAge: 60) directives. Hashtag info changes slowly (cache for 5 minutes), user stats change faster (cache for 30 seconds), and downloads from /download-video/ should not be cached at all because CDN URLs expire.

The cache key must include the API key in some form (hashed) so two customers cannot accidentally read each other's cached responses. The simplest pattern: include the API key hash in the cache scope.

Auth pass-through

The frontend already has the customer's API key (visible on the profile page). The cleanest pattern is: client sends it in Authorization: Bearer <key>, the gateway extracts and forwards it as X-Api-Key to TikLiveAPI. Each request lives in its own context so a key from request A never leaks into request B's loader cache.

const server = new ApolloServer({ schema });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => {
    const auth = req.headers.authorization ?? '';
    const apiKey = auth.replace(/^Bearer\s+/i, '').trim();
    if (!apiKey) throw new GraphQLError('Missing API key', { extensions: { code: 'UNAUTHENTICATED' } });
    return { apiKey, loaders: makeLoaders(apiKey) };
  },
});

Subscriptions for pseudo-real-time

TikLiveAPI does not push events - there are no webhooks, and the upstream is request-response only. If you want a "live comment count" feel, the pattern is: GraphQL subscription on the server that polls /post-detail/ every 5 seconds and pushes the delta down to subscribed clients over WebSockets. The client thinks it is real-time; only your gateway knows it is polling. Budget this carefully - each subscriber costs 12 credits per minute at that polling rate.

Minimal Apollo Server skeleton

import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { GraphQLError } from 'graphql';
import DataLoader from 'dataloader';

const typeDefs = `#graphql
  type Query {
    user(username: String!): User
    post(url: String!): Post
  }
  type User {
    id: ID!
    uniqueId: String!
    nickname: String!
    verified: Boolean!
    stats: UserStats!
    posts(first: Int = 10, after: String): PostConnection!
  }
  type UserStats { followerCount: Int!; heartCount: Int!; videoCount: Int! }
  type Post {
    id: ID!
    awemeId: String!
    title: String
    play: String
    hdplay: String
    playCount: Int!
    diggCount: Int!
    commentCount: Int!
  }
  type PostConnection { edges: [PostEdge!]!; pageInfo: PageInfo! }
  type PostEdge { cursor: String!; node: Post! }
  type PageInfo { hasNextPage: Boolean!; endCursor: String }
`;

const BASE = 'https://api.tikliveapi.com';

async function call(apiKey: string, path: string, params: Record<string, string | number> = {}) {
  const url = new URL(path, BASE);
  for (const [k, v] of Object.entries(params)) if (v !== '') url.searchParams.set(k, String(v));
  const r = await fetch(url, { headers: { 'X-Api-Key': apiKey } });
  if (!r.ok) throw new GraphQLError(`Upstream ${r.status}`, { extensions: { code: 'UPSTREAM' } });
  return r.json();
}

const resolvers = {
  Query: {
    user: async (_: unknown, a: { username: string }, ctx: any) => {
      const raw = await ctx.loaders.userByUsername.load(a.username);
      return { ...raw.user, stats: raw.stats };
    },
  },
  User: {
    posts: async (parent: any, a: { first: number; after?: string }, ctx: any) => {
      const d = await call(ctx.apiKey, '/user-posts/', { userid: parent.id, count: a.first, cursor: a.after ?? '' });
      return {
        edges: d.videos.map((v: any) => ({
          cursor: v.aweme_id,
          node: {
            id: v.aweme_id, awemeId: v.aweme_id, title: v.title, play: v.play, hdplay: v.hdplay,
            playCount: v.play_count, diggCount: v.digg_count, commentCount: v.comment_count,
          },
        })),
        pageInfo: { hasNextPage: d.hasMore, endCursor: d.cursor },
      };
    },
  },
};

const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: async ({ req }) => {
    const apiKey = (req.headers.authorization ?? '').replace(/^Bearer\s+/i, '').trim();
    if (!apiKey) throw new GraphQLError('Missing API key', { extensions: { code: 'UNAUTHENTICATED' } });
    return {
      apiKey,
      loaders: {
        userByUsername: new DataLoader<string, any>(async (names) => {
          const u = [...new Set(names)];
          const res = await Promise.all(u.map((n) => call(apiKey, '/userinfo-by-username/', { username: n })));
          const m = new Map(u.map((n, i) => [n, res[i]]));
          return names.map((n) => m.get(n));
        }),
      },
    };
  },
});

console.log(`GraphQL ready at ${url}`);

That is a complete working wrapper. Drop it into a Node 20 project with @apollo/server, graphql, and dataloader installed, point your frontend at port 4000, and try queries from the playground against the underlying endpoints to compare shapes.

FAQ

Will the GraphQL layer increase my credit consumption?

No. Each resolver still maps to one upstream call. DataLoader deduplication can actually decrease credits when a query references the same author or hashtag multiple times. The wrapper is request-shape transformation, not a billing layer.

Should I cache responses inside the wrapper?

For semi-static data like hashtag info (/challenge-info-name/) and music info, yes - 5 to 15 minute TTLs work well. For user stats and post counters, cache aggressively only if your product can tolerate slightly stale numbers. Never cache download URLs from /download-video/; CDN signatures expire.

How do I handle the two pagination styles?

Hide them. The wrapper exposes a single Relay-style cursor argument to clients. Inside the followers resolver, you translate the cursor back into the time param TikLiveAPI expects. Inside the posts resolver, you pass it through as cursor. The frontend never knows the difference.

Can I expose the playground inside my GraphQL server?

Apollo Server ships with Apollo Sandbox at the root URL in dev mode. Disable it in production and keep the upstream TikLiveAPI playground for raw REST testing. Two tools, two purposes.

What if I just want to try TikLiveAPI without building a wrapper first?

That is the right starting point. Register an account, grab an API key, hit two or three endpoints with curl. Build the GraphQL layer only when your client code has three or more concurrent REST calls per page render. See pricing for the pay-as-you-go credits, and contact us if you want a hand reviewing a schema draft. The full endpoint catalog is on the blog and in the documentation.

Build with the TikTok API

Ready to put what you read into code? Try our endpoints live or grab the full reference.

Open Playground Read Documentation