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.
Before anything else, honest evaluation. GraphQL's benefits are real but so are the costs.
Use GraphQL when:
Stick with REST when:
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.
The Node.js GraphQL ecosystem has a few common choices:
For most new TypeScript projects, GraphQL Yoga + Pothos is the pragmatic modern choice. Apollo Server is also fine, especially if you want the ecosystem.
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.
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.
Let's build a minimal GraphQL API: users with posts, with a query, a mutation, and N+1 protection.
npm install graphql graphql-yoga @pothos/core
npm install -D @types/node typescript
// 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();
// 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);
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.
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.
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.
Without limits, a GraphQL query can fetch arbitrary amounts of data in one request. Production APIs need:
graphql-depth-limit).graphql-query-complexity).import { createYoga } from "graphql-yoga";
import depthLimit from "graphql-depth-limit";
createYoga({
schema,
validationRules: [depthLimit(10)],
});
GraphQL's flexibility complicates caching. A single endpoint means traditional HTTP cache layers don't work the same way. Options:
For most apps, client-side caching is enough. Add persisted queries when you need HTTP caching.
Same considerations as any Node.js backend: container, PaaS, Lambda, or a managed platform. GraphQL-specific things:
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:
When you still want GraphQL:
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.
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.
# 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
Want to jump straight to a running app? Clone this starter and deploy it to your own cloud.