GraphQL + TypeScript: Type-safe APIs End to End (2026)

GraphQL and TypeScript are a natural match — GraphQL schemas are typed by definition, and TypeScript gives you the tooling to enforce those types all the way from the server resolver to the React component. The missing piece is automating the connection between them. That's what this guide covers. We'll go from schema definition to fully typed React hooks, with Apollo Server 4 on the backend, graphql-code-generator automating the types, and Apollo Client 4 with real subscriptions on the frontend
GraphQL and TypeScript are a natural match — GraphQL schemas are typed by definition, and TypeScript gives you the tooling to enforce those types all the way from the server resolver to the React component. The missing piece is automating the connection between them. That's what this guide covers.
We'll go from schema definition to fully typed React hooks, with Apollo Server 4 on the backend, graphql-code-generator automating the types, and Apollo Client 4 with real subscriptions on the frontend.
When GraphQL vs REST vs tRPC
Before building anything, confirm GraphQL is the right choice:
GraphQL makes sense when:
- Multiple clients (web, mobile, third-party) need different shapes of the same data
- You have deeply nested relational data and want to avoid over-fetching
- You're building a public API that external developers will consume
- Your team already has a GraphQL schema as the contract between frontend and backend
REST makes sense when:
- Simple CRUD operations with predictable data shapes
- Team is small and the overhead of schemas + codegen isn't worth it
- Caching at the HTTP level matters (CDN caching is trivial with REST)
- File uploads are a primary concern (GraphQL handles these awkwardly)
tRPC makes sense when:
- Full TypeScript monorepo (Next.js + shared types)
- No external consumers — it's purely an internal API
- You want RPC ergonomics without any schema language
For greenfield projects with multiple clients or when the API will be consumed externally, GraphQL's type system and introspection capabilities are worth the setup cost.
Schema Design (SDL)
The schema is the contract. Write it first, before any resolver code:
# schema.graphql
type Query {
posts(page: Int, pageSize: Int, tag: String): PostsResult!
post(slug: String!): Post
me: User
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
commentAdded(postId: ID!): Comment!
}
type PostsResult {
posts: [Post!]!
total: Int!
hasNextPage: Boolean!
}
type Post {
id: ID!
title: String!
slug: String!
content: String!
excerpt: String
published: Boolean!
publishedAt: String
views: Int!
author: User!
tags: [Tag!]!
comments: [Comment!]!
commentCount: Int!
}
type User {
id: ID!
name: String!
email: String!
avatar: String
role: Role!
}
type Tag {
id: ID!
name: String!
slug: String!
}
type Comment {
id: ID!
content: String!
author: User!
createdAt: String!
post: Post!
}
enum Role {
USER
ADMIN
MODERATOR
}
input CreatePostInput {
title: String!
content: String!
tags: [String!]
published: Boolean
}
input UpdatePostInput {
title: String
content: String
tags: [String!]
published: Boolean
}
Enter fullscreen mode Exit fullscreen mode
The schema drives everything — codegen reads it to generate TypeScript types, client queries are validated against it, and it's what external consumers see.
GraphQL Context
Context is shared across all resolvers in a single request. Use it for auth and database access:
// src/context.ts
import { PrismaClient } from '@prisma/client'
import jwt from 'jsonwebtoken'
const db = new PrismaClient()
export interface Context {
db: typeof db
user: { id: string; role: string } | null
}
export function createContext({ req }: { req: Request }): Context {
const token = req.headers.get('authorization')?.replace('Bearer ', '')
let user = null
if (token) {
try {
user = jwt.verify(token, process.env.JWT_SECRET!) as { id: string; role: string }
} catch {
// invalid token — treat as unauthenticated
}
}
return { db, user }
}
Enter fullscreen mode Exit fullscreen mode
Resolvers
Resolvers are functions that fetch or mutate data for each field. Keep them thin — business logic goes in service functions:
// src/resolvers.ts
import { Resolvers } from './__generated__/resolvers'
import { GraphQLError } from 'graphql'
export const resolvers: Resolvers = {
Query: {
posts: async (_, { page = 1, pageSize = 10, tag }, { db }) => {
const where = tag
? { published: true, tags: { some: { tag: { slug: tag } } } }
: { published: true }
const [posts, total] = await Promise.all([
db.post.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
orderBy: { publishedAt: 'desc' },
include: { author: true, tags: { include: { tag: true } } }
}),
db.post.count({ where })
])
return {
posts,
total,
hasNextPage: page * pageSize < total
}
},
post: async (_, { slug }, { db }) => {
return db.post.findUnique({
where: { slug },
include: { author: true, tags: { include: { tag: true } }, comments: true }
})
},
me: async (_, __, { user, db }) => {
if (!user) return null
return db.user.findUnique({ where: { id: user.id } })
}
},
Mutation: {
createPost: async (_, { input }, { user, db }) => {
if (!user) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' }
})
}
return db.post.create({
data: {
title: input.title,
slug: input.title.toLowerCase().replace(/\s+/g, '-'),
content: input.content,
published: input.published ?? false,
publishedAt: input.published ? new Date() : null,
authorId: user.id
},
include: { author: true, tags: { include: { tag: true } } }
})
},
deletePost: async (_, { id }, { user, db }) => {
if (!user) throw new GraphQLError('Not authenticated', { extensions: { code: 'UNAUTHENTICATED' } })
const post = await db.post.findUnique({ where: { id } })
if (!post) throw new GraphQLError('Post not found', { extensions: { code: 'NOT_FOUND' } })
if (post.authorId !== user.id && user.role !== 'ADMIN') {
throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } })
}
await db.post.delete({ where: { id } })
return true
}
},
Subscription: {
commentAdded: {
subscribe: (_, { postId }, { pubsub }) => pubsub.asyncIterableIterator(`COMMENT_ADDED_${postId}`),
resolve: (payload) => payload
}
},
Post: {
commentCount: (post, _, { db }) => db.comment.count({ where: { postId: post.id } })
}
}
Enter fullscreen mode Exit fullscreen mode
Apollo Server 4
Apollo Server 4 moved to a framework-agnostic core with explicit adapters:
// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { makeExecutableSchema } from '@graphql-tools/schema'
import { readFileSync } from 'fs'
import { resolvers } from './resolvers'
import { createContext } from './context'
const typeDefs = readFileSync('./schema.graphql', 'utf-8')
const schema = makeExecutableSchema({ typeDefs, resolvers })
const server = new ApolloServer({ schema })
const { url } = await startStandaloneServer(server, {
context: createContext,
listen: { port: 4000 }
})
console.log(`Server ready at ${url}`)
Enter fullscreen mode Exit fullscreen mode
For Next.js App Router, use the @as-integrations/next package instead of standalone server:
// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { schema } from '@/lib/graphql/schema'
import { createContext } from '@/lib/graphql/context'
const server = new ApolloServer({ schema })
const handler = startServerAndCreateNextHandler(server, {
context: async (req) => createContext({ req })
})
export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode
GraphQL Yoga — The Edge Alternative
GraphQL Yoga is worth knowing for edge runtimes where Apollo's Node.js dependencies are too heavy:
// app/api/graphql/route.ts (Yoga version)
import { createYoga } from 'graphql-yoga'
import { schema } from '@/lib/graphql/schema'
const { handleRequest } = createYoga({
schema,
graphqlEndpoint: '/api/graphql',
fetchAPI: { Response, Request, ReadableStream }
})
export { handleRequest as GET, handleRequest as POST }
Enter fullscreen mode Exit fullscreen mode
Yoga runs on Cloudflare Workers, Vercel Edge, and Deno — Apollo Server does not without Prisma Accelerate or a proxy.
Code Generator — Automatic Types
This is the piece that makes GraphQL + TypeScript actually ergonomic. graphql-code-generator reads your schema and query files and generates typed hooks automatically.
npm install -D @graphql-codegen/cli @graphql-codegen/client-preset
npx graphql-code-generator init
Enter fullscreen mode Exit fullscreen mode
# codegen.yml
schema: 'http://localhost:4000/graphql'
documents: 'src/**/*.graphql'
generates:
src/__generated__/:
preset: client
config:
documentMode: string
src/__generated__/resolvers.ts:
plugins:
- typescript
- typescript-resolvers
config:
contextType: '../context#Context'
mappers:
Post: '@prisma/client#Post'
User: '@prisma/client#User'
Enter fullscreen mode Exit fullscreen mode
With mappers, the resolver types expect Prisma models as parent arguments — TypeScript will error if you return a shape that doesn't match.
Write .graphql files co-located with your components:
# src/features/blog/queries.graphql
query GetPosts($page: Int, $tag: String) {
posts(page: $page, tag: $tag) {
posts {
...PostCard
}
total
hasNextPage
}
}
query GetPost($slug: String!) {
post(slug: $slug) {
...PostDetail
}
}
fragment PostCard on Post {
id
title
slug
excerpt
publishedAt
author {
name
avatar
}
}
fragment PostDetail on Post {
id
title
slug
content
publishedAt
views
author {
id
name
avatar
}
tags {
name
slug
}
}
Enter fullscreen mode Exit fullscreen mode
Run codegen:
npx graphql-code-generator --watch
# watches .graphql files and regenerates on change
Enter fullscreen mode Exit fullscreen mode
The generated hooks are fully typed — no manual TypeScript interfaces:
import { useGetPostsQuery } from '@/__generated__/graphql'
function PostList({ tag }: { tag?: string }) {
const { data, loading, error, fetchMore } = useGetPostsQuery({
variables: { page: 1, tag }
})
if (loading) return <Spinner />
if (error) return <Error message={error.message} />
// data.posts.posts is Post[] — fully typed from the fragment
return (
<div>
{data?.posts.posts.map(post => (
<PostCard key={post.id} post={post} />
))}
{data?.posts.hasNextPage && (
<button onClick={() => fetchMore({ variables: { page: 2 } })}>
Load more
</button>
)}
</div>
)
}
Enter fullscreen mode Exit fullscreen mode
Apollo Client 4 Setup
// src/lib/apolloClient.ts
import { ApolloClient, InMemoryCache, createHttpLink, ApolloLink } from '@apollo/client'
import { setContext } from '@apollo/client/link/context'
import { onError } from '@apollo/client/link/error'
const httpLink = createHttpLink({ uri: '/api/graphql' })
const authLink = setContext((_, { headers }) => {
const token = localStorage.getItem('token')
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : ''
}
}
})
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, extensions }) => {
if (extensions?.code === 'UNAUTHENTICATED') {
localStorage.removeItem('token')
window.location.href = '/login'
}
})
}
if (networkError) {
console.error('Network error:', networkError)
}
})
export const client = new ApolloClient({
link: ApolloLink.from([errorLink, authLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
posts: {
keyArgs: ['tag'],
merge(existing, incoming, { args }) {
const merged = existing ? [...existing.posts] : []
const offset = ((args?.page ?? 1) - 1) * (args?.pageSize ?? 10)
for (let i = 0; i < incoming.posts.length; ++i) {
merged[offset + i] = incoming.posts[i]
}
return { ...incoming, posts: merged }
}
}
}
}
}
})
})
Enter fullscreen mode Exit fullscreen mode
The typePolicies configure the normalized cache to merge paginated results instead of replacing them — subsequent page loads append to the existing list.
Mutations with Cache Updates
import { useCreatePostMutation, GetPostsDocument } from '@/__generated__/graphql'
function CreatePostForm() {
const [createPost, { loading }] = useCreatePostMutation({
update(cache, { data }) {
if (!data?.createPost) return
// Evict the posts list cache to force a refetch
// More surgical than refetchQueries for paginated lists
cache.evict({ fieldName: 'posts' })
cache.gc()
},
onError(error) {
toast.error(error.message)
}
})
return (
<form onSubmit={async (e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
await createPost({
variables: {
input: {
title: formData.get('title') as string,
content: formData.get('content') as string
}
}
})
}}>
<input name="title" required />
<textarea name="content" required />
<button type="submit" disabled={loading}>
{loading ? 'Creating...' : 'Create Post'}
</button>
</form>
)
}
Enter fullscreen mode Exit fullscreen mode
Subscriptions
Subscriptions require a WebSocket link. Split the link — queries and mutations go over HTTP, subscriptions over WebSocket:
// src/lib/apolloClient.ts (add WebSocket support)
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
import { getMainDefinition } from '@apollo/client/utilities'
import { split } from '@apollo/client'
const wsLink = new GraphQLWsLink(
createClient({
url: process.env.NEXT_PUBLIC_WS_URL ?? 'ws://localhost:4000/graphql',
connectionParams: () => ({
authorization: `Bearer ${localStorage.getItem('token') ?? ''}`
})
})
)
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query)
return definition.kind === 'OperationDefinition' && definition.operation === 'subscription'
},
wsLink,
ApolloLink.from([errorLink, authLink, httpLink])
)
export const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache(/* ... */)
})
Enter fullscreen mode Exit fullscreen mode
Subscription query file:
# src/features/comments/subscriptions.graphql
subscription OnCommentAdded($postId: ID!) {
commentAdded(postId: $postId) {
id
content
createdAt
author {
name
avatar
}
}
}
Enter fullscreen mode Exit fullscreen mode
Using the generated subscription hook with cache updates:
import { useOnCommentAddedSubscription } from '@/__generated__/graphql'
import { GetPostDocument } from '@/__generated__/graphql'
function CommentSection({ postId }: { postId: string }) {
useOnCommentAddedSubscription({
variables: { postId },
onData({ client, data }) {
const newComment = data.data?.commentAdded
if (!newComment) return
// Update the post's comments in the cache
client.cache.modify({
id: client.cache.identify({ __typename: 'Post', id: postId }),
fields: {
comments(existingComments = []) {
const commentRef = client.cache.writeFragment({
data: newComment,
fragment: /* GraphQL */ `
fragment NewComment on Comment {
id content createdAt
author { name avatar }
}
`
})
return [...existingComments, commentRef]
}
}
})
}
})
// ... render comments
}
Enter fullscreen mode Exit fullscreen mode
Fragments for Component Colocation
Fragments let each component declare exactly what data it needs. The query at the page level composes them:
# PostCard.graphql
fragment PostCard on Post {
id slug title excerpt publishedAt
author { name avatar }
}
# PostDetail.graphql
fragment PostDetail on Post {
...PostCard
content views
tags { name slug }
comments { id content createdAt author { name avatar } }
}
# PostsPage.graphql
query GetPosts($page: Int, $tag: String) {
posts(page: $page, tag: $tag) {
posts { ...PostCard }
total hasNextPage
}
}
Enter fullscreen mode Exit fullscreen mode
The component receives exactly the fragment shape — no unused fields, no missing fields:
import { PostCardFragment } from '@/__generated__/graphql'
function PostCard({ post }: { post: PostCardFragment }) {
// TypeScript knows post.title, post.slug, post.author.name
// It does NOT know post.content (not in this fragment)
return <article>...</article>
}
Enter fullscreen mode Exit fullscreen mode
DataLoader — Solving N+1 Queries
Without DataLoader, each post's author field triggers a separate database query:
// This causes N+1 — one db query per post
Post: {
author: async (post, _, { db }) => {
return db.user.findUnique({ where: { id: post.authorId } })
}
}
Enter fullscreen mode Exit fullscreen mode
With DataLoader, all author lookups within a single request are batched into one query:
import DataLoader from 'dataloader'
// In context setup
export function createLoaders(db: PrismaClient) {
return {
userById: new DataLoader<string, User>(async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } }
})
const userMap = new Map(users.map(u => [u.id, u]))
// Must return results in the same order as ids
return ids.map(id => userMap.get(id) ?? new Error(`User ${id} not found`))
})
}
}
export interface Context {
db: PrismaClient
user: AuthUser | null
loaders: ReturnType<typeof createLoaders>
}
// In resolvers
Post: {
author: (post, _, { loaders }) => {
return loaders.userById.load(post.authorId)
// 20 posts → 20 load() calls → 1 database query
}
}
Enter fullscreen mode Exit fullscreen mode
DataLoader automatically batches all load() calls made within a single tick of the event loop, then calls your batch function once with all the IDs.
Apollo Client vs urql vs React Query
| Apollo Client 4 | urql | React Query + fetch | |
|---|---|---|---|
| Normalized cache | Yes | Yes (opt-in) | No |
| Bundle size | ~47kb | ~18kb | ~13kb + fetch layer |
| Subscriptions | Built-in | Built-in | Manual WebSocket |
| Devtools | Excellent | Good | Excellent |
| Learning curve | Steeper | Moderate | Lowest |
| Best for | Large apps with complex caching | Balanced projects | Simple read-heavy apps |
Apollo Client is the right choice when you have complex cache invalidation requirements, need subscriptions, or your team is already invested in the Apollo ecosystem.
urql is lighter and simpler — good for projects where you want GraphQL without the full Apollo mental model.
React Query + a thin fetch layer (like graphql-request) works when you just want typed data fetching without a full GraphQL client. You lose normalized caching and have to manage cache keys manually, but the API is simpler.
Error Handling
GraphQL errors come back with 200 status codes — you need to check error from Apollo, not catch HTTP errors:
const { data, loading, error } = useGetPostQuery({ variables: { slug } })
if (error) {
// Check for specific error codes
const notFound = error.graphQLErrors.some(e => e.extensions?.code === 'NOT_FOUND')
if (notFound) return <NotFound />
return <ErrorMessage message={error.message} />
}
Enter fullscreen mode Exit fullscreen mode
For mutations, errors can come back as part of the success response (union types) or as GraphQLError in the errors array. The union type pattern is more explicit:
type CreatePostResult = Post | ValidationError | AuthError
type ValidationError {
field: String!
message: String!
}
type AuthError {
message: String!
}
Enter fullscreen mode Exit fullscreen mode
This lets clients do exhaustive type checking rather than catching errors in a separate channel.
Full article at stacknotice.com/blog/graphql-typescript-complete-guide-2026