GraphQL Modern Guide 2026 — Build APIs with Apollo, Pothos, and React Query
Advertisement
Introduction
Why This Matters
GraphQL solves over-fetching and under-fetching in a way REST cannot match. In 2026, the code-first schema approach (Pothos) eliminates the schema-resolver drift that made SDL-first GraphQL painful. Combined with TanStack Query on the client, you get end-to-end type safety without manual type generation for simple cases.
Server Setup with Apollo Server 4
npm install @apollo/server graphql @pothos/core @pothos/plugin-prisma// src/graphql/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { schema } from './schema'
import { createContext } from './context'
const server = new ApolloServer({
schema,
formatError: (formattedError, error) => {
// Never expose stack traces in production
if (process.env.NODE_ENV === 'production') {
return { message: formattedError.message, extensions: { code: formattedError.extensions?.code } }
}
return formattedError
},
})
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: createContext,
})
console.log(`GraphQL server at ${url}`)Code-First Schema with Pothos
Pothos generates SDL from TypeScript — no schema/resolver drift:
// src/graphql/schema.ts
import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import { prisma } from '../lib/prisma'
import type PrismaTypes from '@pothos/plugin-prisma/generated'
const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes
Context: { userId: string | null }
}>({
plugins: [PrismaPlugin],
prisma: { client: prisma },
})
// Define Prisma-backed types
builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
name: t.exposeString('name'),
email: t.exposeString('email'),
posts: t.relation('posts'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
}),
})
builder.prismaObject('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
content: t.exposeString('content', { nullable: true }),
published: t.exposeBoolean('published'),
author: t.relation('author'),
}),
})
// Query type
builder.queryType({
fields: (t) => ({
user: t.prismaField({
type: 'User',
nullable: true,
args: { id: t.arg.id({ required: true }) },
resolve: (query, _, args) =>
prisma.user.findUnique({ ...query, where: { id: args.id } }),
}),
posts: t.prismaConnection({
type: 'Post',
cursor: 'id',
resolve: (query, _, args) =>
prisma.post.findMany({ ...query, where: { published: true } }),
}),
}),
})
export const schema = builder.toSchema()Mutations and Input Types
// Mutations with input validation
const CreatePostInput = builder.inputType('CreatePostInput', {
fields: (t) => ({
title: t.string({ required: true }),
content: t.string(),
published: t.boolean({ defaultValue: false }),
}),
})
builder.mutationType({
fields: (t) => ({
createPost: t.prismaField({
type: 'Post',
authScopes: { loggedIn: true },
args: { input: t.arg({ type: CreatePostInput, required: true }) },
resolve: async (query, _, { input }, ctx) => {
if (!ctx.userId) throw new Error('Not authenticated')
return prisma.post.create({
...query,
data: {
title: input.title,
content: input.content ?? null,
published: input.published ?? false,
authorId: ctx.userId,
},
})
},
}),
}),
})DataLoader for N+1 Prevention
// src/graphql/loaders.ts
import DataLoader from 'dataloader'
import { prisma } from '../lib/prisma'
export function createLoaders() {
return {
userById: new DataLoader<string, User | null>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: [...ids] } },
})
const userMap = new Map(users.map(u => [u.id, u]))
return ids.map(id => userMap.get(id) ?? null)
}),
postsByAuthorId: new DataLoader<string, Post[]>(async (authorIds) => {
const posts = await prisma.post.findMany({
where: { authorId: { in: [...authorIds] } },
})
return authorIds.map(id => posts.filter(p => p.authorId === id))
}),
}
}Client-Side with TanStack Query
// src/hooks/usePosts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
const GET_POSTS = `
query GetPosts($first: Int) {
posts(first: $first) {
edges {
node { id title published author { name } }
}
}
}
`
async function gqlFetch<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
const res = await fetch('/graphql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
})
const { data, errors } = await res.json()
if (errors) throw new Error(errors[0].message)
return data
}
export function usePosts(first = 20) {
return useQuery({
queryKey: ['posts', first],
queryFn: () => gqlFetch(GET_POSTS, { first }),
staleTime: 60_000,
})
}Common Mistakes
- Fetching related data in resolvers without DataLoader — causes N+1 queries
- Exposing internal error messages in production — use
formatErrorto sanitize - Returning every field by default — use Pothos field visibility or auth scopes
- Not paginating list queries — unbounded queries can fetch millions of rows
- Skipping persisted queries or query depth limits — enables DoS via deeply nested queries
Best Practices
- Use Pothos or a code-first library to keep schema and resolvers in sync automatically
- Always use DataLoader for any resolver that fetches by a parent ID
- Set a query depth limit and complexity budget with a library like
graphql-depth-limit - Use cursor-based pagination (
after/first) instead of offset pagination for large datasets - Generate TypeScript types from schema using
@graphql-codegenfor client safety
Key Takeaways
- Code-first GraphQL (Pothos) eliminates schema/resolver drift common in SDL-first approaches
- DataLoader batches and deduplicates database calls to prevent N+1 query problems
- Apollo Server 4 uses a middleware-agnostic architecture compatible with any Node.js framework
- Cursor-based pagination is more reliable than offset pagination for live datasets
formatErrorshould sanitize error messages in production to avoid leaking internals- GraphQL subscriptions enable real-time features without polling — backed by WebSockets or SSE
- Query complexity limits prevent deeply-nested malicious queries from overloading the server
- TanStack Query with a typed
gqlFetchwrapper provides excellent DX without a heavy client library
Advertisement
Related reading
GraphQL with TypeScript — Schema, Resolvers, and Apollo Server 20246 min readAPI Versioning in Node.js and TypeScript — Complete 2026 Guide5 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readREST vs GraphQL vs tRPC — Complete API Comparison 20267 min readHono RPC — End-to-End Type Safety Without tRPC or GraphQL7 min readtRPC in Production — End-to-End Type Safety Without the GraphQL Overhead6 min read