GraphQL Complete Guide 2026: Build APIs with Apollo, Pothos, and React Query
Advertisement
GraphQL in 2026: Still Relevant, More Powerful
REST is great for simple CRUD. GraphQL wins when: your frontend needs flexible data fetching, you have multiple clients with different needs, or you're building a public API used by third parties.
- Schema-First vs Code-First
- Code-First Schema with Pothos
- Apollo Server 4 Setup
- The N+1 Problem and DataLoader
- Subscriptions (Real-Time)
- React Frontend with GraphQL + React Query
- Performance: Persisted Queries
- REST vs GraphQL Decision Guide
Schema-First vs Code-First
Schema-first: Write SDL, then implement resolvers. Good for API design first. Code-first: Write TypeScript classes/functions, generate SDL. Better type safety.
# Code-first with Pothos (recommended in 2026)
npm install @pothos/core graphql graphql-yoga
npm install @pothos/plugin-prisma @pothos/plugin-relay @pothos/plugin-errors
Code-First Schema with Pothos
// src/schema/builder.ts
import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import ErrorsPlugin from '@pothos/plugin-errors'
import type PrismaTypes from '@pothos/plugin-prisma/generated'
import { prisma } from '../lib/db'
export const builder = new SchemaBuilder<{
PrismaTypes: PrismaTypes
Context: { userId: string | null }
Scalars: {
DateTime: { Input: Date; Output: Date }
}
}>({
plugins: [PrismaPlugin, ErrorsPlugin],
prisma: { client: prisma },
errors: { defaultTypes: [Error] },
})
builder.queryType({})
builder.mutationType({})
// src/schema/user.ts
import { builder } from './builder'
const User = builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email'),
name: t.exposeString('name'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
posts: t.relation('posts'),
}),
})
builder.queryFields((t) => ({
user: t.prismaField({
type: 'User',
nullable: true,
args: { id: t.arg.string({ required: true }) },
resolve: (query, _, { id }) =>
prisma.user.findUnique({ ...query, where: { id } }),
}),
users: t.prismaConnection({
type: 'User',
cursor: 'id',
resolve: (query) => prisma.user.findMany({ ...query }),
}),
}))
builder.mutationFields((t) => ({
createUser: t.prismaField({
type: 'User',
args: {
email: t.arg.string({ required: true }),
name: t.arg.string({ required: true }),
password: t.arg.string({ required: true }),
},
resolve: async (query, _, args) => {
const hash = await bcrypt.hash(args.password, 12)
return prisma.user.create({
...query,
data: { email: args.email, name: args.name, passwordHash: hash },
})
},
}),
}))
Apollo Server 4 Setup
// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { schema } from './schema'
import { verifyToken } from './lib/auth'
const server = new ApolloServer({
schema,
introspection: process.env.NODE_ENV !== 'production',
formatError: (formattedError, error) => {
// Log internally, send safe message to client
console.error(error)
if (process.env.NODE_ENV === 'production') {
return { message: 'An error occurred', extensions: formattedError.extensions }
}
return formattedError
},
})
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => {
const token = req.headers.authorization?.replace('Bearer ', '')
const user = token ? verifyToken(token) : null
return { userId: user?.sub ?? null }
},
listen: { port: 4000 },
})
console.log(`GraphQL server ready at ${url}`)
The N+1 Problem and DataLoader
// PROBLEM: This makes N+1 queries
const Post = builder.prismaObject('Post', {
fields: (t) => ({
id: t.exposeID('id'),
title: t.exposeString('title'),
author: t.field({
type: User,
// For 100 posts, this makes 100 separate queries!
resolve: (post) => prisma.user.findUnique({ where: { id: post.authorId } }),
}),
}),
})
// SOLUTION: DataLoader batches queries
import DataLoader from 'dataloader'
function createLoaders() {
return {
userLoader: new DataLoader<string, User>(async (ids) => {
const users = await prisma.user.findMany({
where: { id: { in: ids as string[] } },
})
const userMap = new Map(users.map(u => [u.id, u]))
return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
}),
}
}
// Add to context, use in resolvers
const Post = builder.prismaObject('Post', {
fields: (t) => ({
author: t.field({
type: User,
resolve: (post, _, ctx) => ctx.loaders.userLoader.load(post.authorId),
// Now 100 posts = 1 batched query
}),
}),
})
Subscriptions (Real-Time)
import { createPubSub } from 'graphql-yoga'
const pubSub = createPubSub<{
newPost: [userId: string, payload: Post]
postUpdated: [postId: string, payload: Post]
}>()
builder.subscriptionFields((t) => ({
newPost: t.field({
type: Post,
args: { authorId: t.arg.string() },
subscribe: (_, { authorId }) => pubSub.subscribe('newPost', authorId!),
resolve: (payload) => payload,
}),
}))
// Trigger subscription from mutation
builder.mutationFields((t) => ({
createPost: t.field({
type: Post,
args: {
title: t.arg.string({ required: true }),
content: t.arg.string({ required: true }),
},
resolve: async (_, args, ctx) => {
const post = await prisma.post.create({
data: { ...args, authorId: ctx.userId! },
})
pubSub.publish('newPost', ctx.userId!, post) // Trigger subscription
return post
},
}),
}))
React Frontend with GraphQL + React Query
// Using @tanstack/react-query with graphql-request (lightweight)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { request } from 'graphql-request'
const GRAPHQL_URL = '/api/graphql'
// Type-safe hooks
function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: () => request<{ user: User }>(GRAPHQL_URL, GET_USER_QUERY, { id }),
select: (data) => data.user,
})
}
function useCreateUser() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: CreateUserInput) =>
request<{ createUser: User }>(GRAPHQL_URL, CREATE_USER_MUTATION, { input }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] })
},
})
}
// Usage
function UserProfile({ id }: { id: string }) {
const { data: user, isLoading, error } = useUser(id)
const createUser = useCreateUser()
if (isLoading) return <Skeleton />
if (error) return <ErrorMessage error={error} />
return <div>{user?.name}</div>
}
Performance: Persisted Queries
// Reduce request payload size by sending query hashes
// Client sends hash, server looks up full query
// Apollo Server persisted queries
import { createPersistedQueryLink } from "@apollo/client/link/persisted-queries"
import { sha256 } from 'crypto-hash'
const persistedQueriesLink = createPersistedQueryLink({
sha256,
useGETForHashedQueries: true, // Cache via CDN
})
const client = new ApolloClient({
link: persistedQueriesLink.concat(httpLink),
cache: new InMemoryCache(),
})
REST vs GraphQL Decision Guide
| Scenario | Use REST | Use GraphQL |
|---|---|---|
| Simple CRUD | ✓ | |
| Multiple client types (web + mobile) | ✓ | |
| Deeply nested data | ✓ | |
| Public third-party API | ✓ | ✓ |
| Real-time with WebSockets | ✓ | |
| File uploads | ✓ | |
| Team unfamiliar with GQL | ✓ |
GraphQL is a tool, not a religion. Use it when it solves your specific over-fetching or under-fetching problem.
Advertisement