04/20/26

How to Build a GraphQL API with TypeScript in 2026

Schemas, resolvers, code-first vs schema-first, and what actually ships

8 Min Read

GraphQL is still a good fit for specific problems: client-driven field selection, aggregating across multiple backend services, and APIs where REST's resource shape doesn't match how the frontend actually uses data. It's less good for simple CRUD APIs — REST is often the better fit there.

This guide builds a GraphQL API in TypeScript from schema design through deployment, covering the real decisions: schema-first vs code-first, which server and schema builder to use, type generation, the N+1 problem, and how to handle auth. At the end we look at what changes when you use a backend framework that has type-safe APIs built in — GraphQL is sometimes redundant with them.

Should You Use GraphQL?

Before anything else, honest evaluation. GraphQL's benefits are real but so are the costs.

Use GraphQL when:

  • You have a frontend that needs data aggregated from many backend sources.
  • Your clients (mobile + web + partners) have meaningfully different data needs.
  • You want clients to control field selection to minimize payload size.
  • You already have a GraphQL service and consistency matters.

Stick with REST when:

  • Your API is straightforward CRUD.
  • Your team doesn't have GraphQL experience.
  • Observability and caching are bigger concerns than flexible field selection (REST is simpler to cache).
  • You need tight type safety between server and client (tRPC, Encore, or OpenAPI-generated clients often do this better than GraphQL in practice).

The GraphQL tax — schema management, N+1 queries, query complexity limits, caching invalidation — is substantial. Pay it only when you're getting real value in return.

Picking a Server

The Node.js GraphQL ecosystem has a few common choices:

  • Apollo Server — the incumbent. Full-featured, extensive ecosystem, large community.
  • GraphQL Yoga — lightweight, runs on everything (Node, Deno, Bun, edge runtimes).
  • Mercurius — Fastify-native, good performance.
  • Pothos + GraphQL Yoga — a schema builder + server combo favored for code-first TypeScript.

For most new TypeScript projects, GraphQL Yoga + Pothos is the pragmatic modern choice. Apollo Server is also fine, especially if you want the ecosystem.

Schema-First vs Code-First

Schema-First

You write your schema in .graphql files, generate TypeScript types from it, and implement resolvers that match the generated types.

# schema.graphql
type User {
  id: ID!
  email: String!
  name: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

type Query {
  user(id: ID!): User
  users: [User!]!
}

Then run graphql-codegen to generate:

// generated types
export type Resolvers = {
  Query: {
    user: (parent: {}, args: { id: string }) => Promise<User | null>;
    users: () => Promise<User[]>;
  };
  User: {
    posts: (parent: User) => Promise<Post[]>;
  };
};

Pros: schema is the source of truth, easy for frontend teams to consume.

Cons: two sources of truth to keep in sync (schema + resolver code). Codegen step in the build.

Code-First

You build your schema in TypeScript, and the schema is derived from the code.

// Pothos
import SchemaBuilder from "@pothos/core";

const builder = new SchemaBuilder<{}>({});

const User = builder.objectType("User", {
  fields: (t) => ({
    id: t.exposeID("id"),
    email: t.exposeString("email"),
    name: t.exposeString("name", { nullable: true }),
    posts: t.field({
      type: [Post],
      resolve: (user) => db.posts.findByUser(user.id),
    }),
  }),
});

builder.queryType({
  fields: (t) => ({
    user: t.field({
      type: User,
      nullable: true,
      args: { id: t.arg.id({ required: true }) },
      resolve: (_, { id }) => db.users.findById(id),
    }),
  }),
});

export const schema = builder.toSchema();

Pros: single source of truth, full TypeScript inference, refactoring is natural.

Cons: schema is harder to review at a glance. Frontend teams sometimes prefer a .graphql file.

For TypeScript projects in 2026, code-first with Pothos is the recommended default.

A Working Example

Let's build a minimal GraphQL API: users with posts, with a query, a mutation, and N+1 protection.

Dependencies

npm install graphql graphql-yoga @pothos/core
npm install -D @types/node typescript

Schema definition (Pothos)

// src/schema.ts
import SchemaBuilder from "@pothos/core";
import { db } from "./db";

const builder = new SchemaBuilder<{
  Context: { userId?: string };
}>({});

interface UserModel { id: string; email: string; name: string | null; }
interface PostModel { id: string; title: string; authorId: string; }

const User = builder.objectRef<UserModel>("User").implement({
  fields: (t) => ({
    id: t.exposeID("id"),
    email: t.exposeString("email"),
    name: t.exposeString("name", { nullable: true }),
    posts: t.field({
      type: [Post],
      resolve: (user, _args, ctx) => ctx.loaders.postsByUser.load(user.id),
    }),
  }),
});

const Post = builder.objectRef<PostModel>("Post").implement({
  fields: (t) => ({
    id: t.exposeID("id"),
    title: t.exposeString("title"),
    author: t.field({
      type: User,
      resolve: (post, _args, ctx) => ctx.loaders.userById.load(post.authorId),
    }),
  }),
});

builder.queryType({
  fields: (t) => ({
    user: t.field({
      type: User,
      nullable: true,
      args: { id: t.arg.id({ required: true }) },
      resolve: (_, { id }) => db.users.findById(id as string),
    }),
    me: t.field({
      type: User,
      nullable: true,
      resolve: (_, _args, ctx) =>
        ctx.userId ? db.users.findById(ctx.userId) : null,
    }),
  }),
});

builder.mutationType({
  fields: (t) => ({
    createPost: t.field({
      type: Post,
      args: {
        title: t.arg.string({ required: true }),
      },
      resolve: async (_, { title }, ctx) => {
        if (!ctx.userId) throw new Error("unauthenticated");
        return db.posts.create({ title, authorId: ctx.userId });
      },
    }),
  }),
});

export const schema = builder.toSchema();

Server setup (GraphQL Yoga)

// src/server.ts
import { createYoga } from "graphql-yoga";
import { createServer } from "http";
import DataLoader from "dataloader";
import { schema } from "./schema";
import { db } from "./db";
import { verifyToken } from "./auth";

const yoga = createYoga({
  schema,
  context: ({ request }) => {
    const token = request.headers.get("authorization")?.replace("Bearer ", "");
    const userId = token ? verifyToken(token).userId : undefined;

    return {
      userId,
      loaders: {
        userById: new DataLoader<string, any>(async (ids) => {
          const users = await db.users.findByIds(ids as string[]);
          return ids.map((id) => users.find((u) => u.id === id));
        }),
        postsByUser: new DataLoader<string, any[]>(async (userIds) => {
          const posts = await db.posts.findByUserIds(userIds as string[]);
          return userIds.map((id) => posts.filter((p) => p.authorId === id));
        }),
      },
    };
  },
});

createServer(yoga).listen(4000);

Why DataLoader

The N+1 problem is the first real performance gotcha in GraphQL. A query like:

query {
  users {
    posts {
      title
    }
  }
}

naively issues one query for users, then one query per user for their posts (N+1 queries for N users). DataLoader batches these: every call to ctx.loaders.postsByUser.load(id) in the same tick is collected, and a single findByUserIds runs them all at once.

You need DataLoader in every resolver that crosses a relation. Forgetting to use it is the most common reason GraphQL APIs are slow.

Authentication

GraphQL doesn't prescribe an auth model. Most teams use JWTs at the request layer, check the user in the context, and enforce permissions in resolvers.

// In the resolver
resolve: async (_, args, ctx) => {
  if (!ctx.userId) throw new Error("unauthenticated");
  const user = await db.users.findById(ctx.userId);
  if (user.role !== "admin") throw new Error("forbidden");
  // ...
}

For more complex authorization, use a library like GraphQL Shield to define permission rules declaratively.

Type Generation for Clients

If your frontend uses GraphQL, you want types generated from the schema:

npm install -D @graphql-codegen/cli @graphql-codegen/client-preset
# codegen.yml
schema: http://localhost:4000/graphql
documents: "src/**/*.{ts,tsx}"
generates:
  src/gql/:
    preset: client

Now const { data } = useQuery(gql\query { me { email } }`)hasdata` fully typed.

Query Complexity and Rate Limiting

Without limits, a GraphQL query can fetch arbitrary amounts of data in one request. Production APIs need:

  • Query depth limits (graphql-depth-limit).
  • Query complexity scoring (graphql-query-complexity).
  • Rate limiting per operation, not just per request.
import { createYoga } from "graphql-yoga";
import depthLimit from "graphql-depth-limit";

createYoga({
  schema,
  validationRules: [depthLimit(10)],
});

Caching

GraphQL's flexibility complicates caching. A single endpoint means traditional HTTP cache layers don't work the same way. Options:

  • Client-side caching — Apollo Client, urql, or TanStack Query handle most of this.
  • Response caching — Apollo Server and GraphQL Yoga have response cache plugins.
  • Persisted queries — register queries server-side, let clients reference them by hash, enables HTTP-layer caching.

For most apps, client-side caching is enough. Add persisted queries when you need HTTP caching.

Deployment

Same considerations as any Node.js backend: container, PaaS, Lambda, or a managed platform. GraphQL-specific things:

  • Keep-alive for subscriptions — if you use GraphQL subscriptions over WebSockets, your deployment target needs to support long-lived connections (rules out basic Lambda).
  • Schema versioning — you can't "break" a GraphQL API the way you can a REST endpoint. Deprecate fields, don't remove them.

A Different Take: Type-Safe APIs Without GraphQL

GraphQL's original selling point — "let the client ask for exactly what it needs" — is still valuable when client needs diverge sharply. But for a lot of APIs, the tax (N+1 risk, query complexity, schema management, two build pipelines) outweighs the benefit.

If the problem you're trying to solve is end-to-end type safety between client and server, you might not need GraphQL at all.

Encore.ts generates fully typed client SDKs from TypeScript API definitions automatically:

// Server
import { api } from "encore.dev/api";

interface User { id: string; email: string; name: string; }

export const getUser = api(
  { method: "GET", path: "/users/:id", expose: true },
  async ({ id }: { id: string }): Promise<User> => {
    return await db.queryRow`SELECT * FROM users WHERE id = ${id}`;
  },
);

On the client, the generated SDK gives you:

import Client from "./client";
const client = new Client("https://api.example.com");
const user = await client.users.getUser({ id: "123" }); // fully typed

No GraphQL, no schema file, no DataLoader, no query complexity limits. The typing flows from the TypeScript types.

When this is enough:

  • Your API is RPC-shaped (endpoints, not field selection).
  • Client needs don't diverge dramatically.
  • You want type safety without a separate GraphQL layer.

When you still want GraphQL:

  • Third-party developers or partners consume your API and need field selection.
  • You're aggregating across many backend services and a unified graph genuinely helps.
  • You already have GraphQL infrastructure.

If you do want GraphQL, Encore.ts can host your Yoga server alongside regular APIs — the framework handles infrastructure and observability, the GraphQL layer handles the schema.

Encore is open source (11k+ GitHub stars) and used in production at companies including Groupon.

Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Getting Started

# GraphQL Yoga + Pothos
npm install graphql graphql-yoga @pothos/core dataloader

# Apollo Server
npm install @apollo/server graphql

# Encore.ts (RPC-style type-safe APIs)
brew install encoredev/tap/encore
encore app create my-app --example=ts/empty
cd my-app && encore run
Deploy with Encore

Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.

Deploy

Ready to build your next backend?

Encore is the Open Source framework for building robust type-safe distributed systems with declarative infrastructure.