APIs
|stacknotice.com
15 min left|
0%
|3,000 words
APIs

GraphQL with TypeScript: Complete Guide (2026)

Build type-safe GraphQL APIs and clients with TypeScript. Apollo Server, GraphQL Yoga, Code Generator, Apollo Client 4, subscriptions, and real Next.js integration patterns.

C
Carlos Oliva
Software Developer
July 24, 202615 min read
Share:
GraphQL with TypeScript: Complete Guide (2026)

GraphQL remains the dominant API layer for complex frontend/backend communication in 2026, particularly in teams where the frontend needs precise control over what data it fetches and when. The tooling has matured significantly — GraphQL Code Generator produces fully typed hooks from your schema automatically, Apollo Client 4 has a cleaner caching model, and schema-first development with SDL gives you a single source of truth that both server and client share.

This guide covers building a complete type-safe GraphQL setup: server with Apollo Server 4 or GraphQL Yoga, automatic type generation, and a Next.js client with typed hooks for queries, mutations, and subscriptions.

GraphQL vs REST vs tRPC in 2026

Before diving in, it's worth being clear about when GraphQL makes sense:

Choose GraphQL when:

  • Multiple clients (web, mobile, third parties) consume the same API with different data needs
  • Your data model has complex relationships and clients need flexible traversal
  • You want to expose a public or semi-public API
  • Real-time subscriptions are a core feature
  • You need fine-grained field-level access control

Choose REST (or Hono/Express) when:

  • Simple CRUD operations with predictable shapes
  • File uploads, webhooks, streaming responses
  • You need HTTP caching at the CDN level
  • Public API that needs to be easy for anyone to call

Choose tRPC when:

  • Full-stack TypeScript monorepo where client and server share the same codebase
  • You want type safety without a schema language or codegen step
  • Internal API consumed only by your own frontend

GraphQL's main overhead is the toolchain — schema, resolvers, codegen, client setup. That overhead pays off in large teams where frontend and backend work independently and the schema becomes a contract between them.

Server: Apollo Server 4

npm install @apollo/server graphql
npm install -D @graphql-codegen/cli

Schema Definition

// src/graphql/schema.ts
import { gql } from 'graphql-tag'
 
export const typeDefs = gql`
  type Query {
    posts(
      page: Int = 1
      limit: Int = 20
      published: Boolean
      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!
    publishPost(id: ID!): Post!
  }
 
  type Subscription {
    postPublished: Post!
    commentAdded(postId: ID!): Comment!
  }
 
  type Post {
    id: ID!
    title: String!
    slug: String!
    content: String!
    excerpt: String
    published: Boolean!
    publishedAt: String
    views: Int!
    createdAt: String!
    author: User!
    tags: [Tag!]!
    comments: [Comment!]!
    commentCount: Int!
  }
 
  type PostsResult {
    posts: [Post!]!
    total: Int!
    page: Int!
    totalPages: Int!
  }
 
  type User {
    id: ID!
    email: String!
    name: String
    avatar: String
    role: UserRole!
    posts: [Post!]!
  }
 
  type Tag {
    id: ID!
    name: String!
    slug: String!
    postCount: Int!
  }
 
  type Comment {
    id: ID!
    content: String!
    createdAt: String!
    author: User!
    post: Post!
  }
 
  input CreatePostInput {
    title: String!
    content: String!
    excerpt: String
    tags: [String!]
    published: Boolean
  }
 
  input UpdatePostInput {
    title: String
    content: String
    excerpt: String
    tags: [String!]
  }
 
  enum UserRole {
    USER
    ADMIN
    MODERATOR
  }
`

Context and Resolvers

// src/graphql/context.ts
import { db } from '../lib/db'
import { getUserFromToken } from '../lib/auth'
import type { User } from '@prisma/client'
 
export interface GraphQLContext {
  db: typeof db
  user: User | null
}
 
export async function createContext({ req }: { req: Request }): Promise<GraphQLContext> {
  const token = req.headers.get('authorization')?.replace('Bearer ', '')
  const user = token ? await getUserFromToken(token) : null
 
  return { db, user }
}
// src/graphql/resolvers/post.ts
import type { GraphQLContext } from '../context'
import type { Post } from '@prisma/client'
import { GraphQLError } from 'graphql'
 
export const postResolvers = {
  Query: {
    posts: async (
      _: unknown,
      args: { page: number; limit: number; published?: boolean; tag?: string },
      ctx: GraphQLContext
    ) => {
      const { page = 1, limit = 20, published, tag } = args
      const skip = (page - 1) * Math.min(limit, 50)
 
      const where = {
        ...(published !== undefined && { published }),
        ...(tag && { tags: { some: { tag: { slug: tag } } } })
      }
 
      const [posts, total] = await Promise.all([
        ctx.db.post.findMany({
          where,
          skip,
          take: Math.min(limit, 50),
          orderBy: { createdAt: 'desc' },
          include: {
            author: true,
            tags: { include: { tag: true } },
            _count: { select: { comments: true } }
          }
        }),
        ctx.db.post.count({ where })
      ])
 
      return {
        posts,
        total,
        page,
        totalPages: Math.ceil(total / limit)
      }
    },
 
    post: async (_: unknown, { slug }: { slug: string }, ctx: GraphQLContext) => {
      return ctx.db.post.findUnique({
        where: { slug },
        include: {
          author: true,
          tags: { include: { tag: true } },
          comments: {
            include: { author: true },
            orderBy: { createdAt: 'desc' },
            take: 20
          },
          _count: { select: { comments: true } }
        }
      })
    },
 
    me: async (_: unknown, __: unknown, ctx: GraphQLContext) => {
      return ctx.user
    }
  },
 
  Mutation: {
    createPost: async (
      _: unknown,
      { input }: { input: { title: string; content: string; excerpt?: string; tags?: string[]; published?: boolean } },
      ctx: GraphQLContext
    ) => {
      if (!ctx.user) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHORIZED' } })
 
      const slug = input.title
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-|-$/g, '')
 
      return ctx.db.post.create({
        data: {
          title: input.title,
          slug,
          content: input.content,
          excerpt: input.excerpt,
          published: input.published ?? false,
          publishedAt: input.published ? new Date() : null,
          authorId: ctx.user.id,
          tags: input.tags ? {
            create: input.tags.map(tagSlug => ({
              tag: {
                connectOrCreate: {
                  where: { slug: tagSlug },
                  create: {
                    name: tagSlug.charAt(0).toUpperCase() + tagSlug.slice(1),
                    slug: tagSlug
                  }
                }
              }
            }))
          } : undefined
        },
        include: { author: true, tags: { include: { tag: true } } }
      })
    },
 
    deletePost: async (_: unknown, { id }: { id: string }, ctx: GraphQLContext) => {
      if (!ctx.user) throw new GraphQLError('Unauthorized', { extensions: { code: 'UNAUTHORIZED' } })
 
      const post = await ctx.db.post.findUnique({ where: { id } })
      if (!post) throw new GraphQLError('Post not found', { extensions: { code: 'NOT_FOUND' } })
      if (post.authorId !== ctx.user.id && ctx.user.role !== 'ADMIN') {
        throw new GraphQLError('Forbidden', { extensions: { code: 'FORBIDDEN' } })
      }
 
      await ctx.db.post.delete({ where: { id } })
      return true
    }
  },
 
  Post: {
    commentCount: (post: Post & { _count?: { comments: number } }) => {
      return post._count?.comments ?? 0
    }
  }
}

Server Setup

// src/server.ts
import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { typeDefs } from './graphql/schema'
import { postResolvers } from './graphql/resolvers/post'
import { createContext } from './graphql/context'
 
const server = new ApolloServer({
  typeDefs,
  resolvers: postResolvers,
  introspection: process.env.NODE_ENV !== 'production',
  formatError: (formattedError, error) => {
    // Don't expose internal errors in production
    if (process.env.NODE_ENV === 'production') {
      if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
        console.error('GraphQL internal error:', error)
        return { message: 'Internal server error' }
      }
    }
    return formattedError
  }
})
 
const { url } = await startStandaloneServer(server, {
  listen: { port: 4000 },
  context: createContext
})
 
console.log(`GraphQL server ready at ${url}`)

Alternative: GraphQL Yoga

GraphQL Yoga is lighter than Apollo Server and integrates better with modern runtimes (Edge, Cloudflare Workers, Bun):

npm install graphql-yoga graphql
// src/yoga.ts
import { createYoga, createSchema } from 'graphql-yoga'
import { typeDefs } from './graphql/schema'
import { postResolvers } from './graphql/resolvers/post'
import { createContext } from './graphql/context'
 
const yoga = createYoga({
  schema: createSchema({ typeDefs, resolvers: postResolvers }),
  context: createContext,
  graphiql: process.env.NODE_ENV !== 'production',
})
 
// Works as a standard Request/Response handler — deploy anywhere
export default yoga

Yoga exports a standard fetch-compatible handler, so it deploys to Next.js API routes, Cloudflare Workers, Bun, and Node.js with the same code.

Code Generator: Automatic TypeScript Types

This is the biggest productivity win in modern GraphQL. GraphQL Code Generator reads your schema and your client queries, then generates typed React hooks automatically:

npm install -D @graphql-codegen/cli @graphql-codegen/client-preset
npx graphql-code-generator init
# codegen.yml
schema: http://localhost:4000/graphql
documents: src/**/*.graphql
generates:
  src/generated/graphql.ts:
    preset: client
    config:
      useTypeImports: true
      enumsAsTypes: true

Write your queries in .graphql files:

# src/graphql/queries/posts.graphql
query GetPosts($page: Int, $limit: Int, $tag: String) {
  posts(page: $page, limit: $limit, tag: $tag) {
    posts {
      id
      title
      slug
      excerpt
      publishedAt
      views
      author {
        id
        name
        avatar
      }
      tags {
        tag {
          id
          name
          slug
        }
      }
      commentCount
    }
    total
    page
    totalPages
  }
}
 
query GetPost($slug: String!) {
  post(slug: $slug) {
    id
    title
    slug
    content
    publishedAt
    views
    author {
      id
      name
      avatar
    }
    tags {
      tag {
        name
        slug
      }
    }
    comments {
      id
      content
      createdAt
      author {
        id
        name
        avatar
      }
    }
    commentCount
  }
}
 
mutation CreatePost($input: CreatePostInput!) {
  createPost(input: $input) {
    id
    title
    slug
  }
}

Run codegen:

npx graphql-code-generator
# or in watch mode during development:
npx graphql-code-generator --watch

This generates src/generated/graphql.ts with typed hooks:

// Auto-generated — don't edit manually
export type GetPostsQuery = {
  posts: {
    posts: Array<{
      id: string
      title: string
      slug: string
      excerpt: string | null
      publishedAt: string | null
      views: number
      author: { id: string; name: string | null; avatar: string | null }
      tags: Array<{ tag: { id: string; name: string; slug: string } }>
      commentCount: number
    }>
    total: number
    page: number
    totalPages: number
  }
}
 
export const GetPostsDocument = ...
export function useGetPostsQuery(...)
export function useGetPostsLazyQuery(...)

Apollo Client 4 in Next.js

npm install @apollo/client graphql
// lib/apollo-client.ts
import { ApolloClient, InMemoryCache, createHttpLink, from } from '@apollo/client'
import { onError } from '@apollo/client/link/error'
import { setContext } from '@apollo/client/link/context'
 
const httpLink = createHttpLink({
  uri: process.env.NEXT_PUBLIC_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
})
 
const authLink = setContext((_, { headers }) => {
  const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null
  return {
    headers: {
      ...headers,
      ...(token ? { authorization: `Bearer ${token}` } : {})
    }
  }
})
 
const errorLink = onError(({ graphQLErrors, networkError }) => {
  if (graphQLErrors) {
    graphQLErrors.forEach(({ message, extensions }) => {
      if (extensions?.code === 'UNAUTHORIZED') {
        localStorage.removeItem('auth_token')
        window.location.href = '/login'
      }
      console.error(`GraphQL error: ${message}`)
    })
  }
  if (networkError) {
    console.error('Network error:', networkError)
  }
})
 
export const apolloClient = new ApolloClient({
  link: from([errorLink, authLink, httpLink]),
  cache: new InMemoryCache({
    typePolicies: {
      Post: {
        keyFields: ['id'],
      },
      Query: {
        fields: {
          posts: {
            keyArgs: ['tag', 'published'],
            merge(existing, incoming, { args }) {
              // Merge paginated results
              const existingPosts = existing?.posts ?? []
              const page = args?.page ?? 1
              if (page === 1) return incoming
              return {
                ...incoming,
                posts: [...existingPosts, ...incoming.posts]
              }
            }
          }
        }
      }
    }
  }),
  defaultOptions: {
    watchQuery: { errorPolicy: 'all' },
    query: { errorPolicy: 'all' },
  }
})
// app/providers.tsx
'use client'
import { ApolloProvider } from '@apollo/client'
import { apolloClient } from '@/lib/apollo-client'
 
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <ApolloProvider client={apolloClient}>
      {children}
    </ApolloProvider>
  )
}

Using Generated Hooks

// app/blog/page.tsx
'use client'
import { useGetPostsQuery } from '@/generated/graphql'
 
export default function BlogPage() {
  const { data, loading, error, fetchMore } = useGetPostsQuery({
    variables: { page: 1, limit: 20 }
  })
 
  if (loading) return <PostsSkeleton />
  if (error) return <p>Error: {error.message}</p>
 
  const { posts, total, page, totalPages } = data!.posts
 
  return (
    <div>
      <h1>Blog ({total} posts)</h1>
      <div className="grid gap-6">
        {posts.map(post => (
          <article key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
            <span>{post.author.name}</span>
            <span>{post.commentCount} comments</span>
          </article>
        ))}
      </div>
      {page < totalPages && (
        <button onClick={() => fetchMore({ variables: { page: page + 1 } })}>
          Load more
        </button>
      )}
    </div>
  )
}
// app/blog/new/page.tsx
'use client'
import { useCreatePostMutation } from '@/generated/graphql'
import { useRouter } from 'next/navigation'
 
export default function NewPostPage() {
  const router = useRouter()
  const [createPost, { loading, error }] = useCreatePostMutation({
    onCompleted: (data) => {
      router.push(`/blog/${data.createPost.slug}`)
    },
    update: (cache, { data }) => {
      // Invalidate the posts list cache after creating
      cache.evict({ fieldName: 'posts' })
    }
  })
 
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
 
    await createPost({
      variables: {
        input: {
          title: formData.get('title') as string,
          content: formData.get('content') as string,
          excerpt: formData.get('excerpt') as string || undefined,
          published: formData.get('published') === 'on'
        }
      }
    })
  }
 
  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Post title" required />
      <textarea name="content" placeholder="Content" required />
      <input name="excerpt" placeholder="Short excerpt" />
      <label>
        <input type="checkbox" name="published" />
        Publish immediately
      </label>
      {error && <p className="text-red-500">{error.message}</p>}
      <button type="submit" disabled={loading}>
        {loading ? 'Creating...' : 'Create Post'}
      </button>
    </form>
  )
}

Real-time Subscriptions

GraphQL subscriptions push updates from server to client over WebSockets:

npm install @apollo/client subscriptions-transport-ws graphql-ws
// lib/apollo-client.ts — add WebSocket link
import { GraphQLWsLink } from '@apollo/client/link/subscriptions'
import { createClient } from 'graphql-ws'
import { split, getMainDefinition } from '@apollo/client'
 
const wsLink = new GraphQLWsLink(
  createClient({
    url: process.env.NEXT_PUBLIC_GRAPHQL_WS_URL ?? 'ws://localhost:4000/graphql',
  })
)
 
// Route subscriptions over WebSocket, queries/mutations over HTTP
const splitLink = split(
  ({ query }) => {
    const definition = getMainDefinition(query)
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    )
  },
  wsLink,
  from([errorLink, authLink, httpLink])
)
 
export const apolloClient = new ApolloClient({
  link: splitLink,
  cache: new InMemoryCache(/* ... */)
})
# src/graphql/subscriptions/comments.graphql
subscription OnCommentAdded($postId: ID!) {
  commentAdded(postId: $postId) {
    id
    content
    createdAt
    author {
      id
      name
      avatar
    }
  }
}
// components/CommentFeed.tsx
'use client'
import { useOnCommentAddedSubscription, useGetPostQuery } from '@/generated/graphql'
 
export function CommentFeed({ postId }: { postId: string }) {
  const { data: postData } = useGetPostQuery({ variables: { slug: postId } })
 
  // Subscription adds new comments in real time
  useOnCommentAddedSubscription({
    variables: { postId },
    onData: ({ client, data }) => {
      const newComment = data.data?.commentAdded
      if (!newComment) return
 
      // Add to Apollo cache directly
      client.cache.modify({
        id: client.cache.identify({ __typename: 'Post', id: postId }),
        fields: {
          comments: (existingComments = []) => [newComment, ...existingComments],
          commentCount: (count: number) => count + 1
        }
      })
    }
  })
 
  return (
    <ul>
      {postData?.post?.comments.map(comment => (
        <li key={comment.id}>
          <strong>{comment.author.name}</strong>
          <p>{comment.content}</p>
        </li>
      ))}
    </ul>
  )
}

Fragments: Colocation with Components

Fragments let each component declare exactly what data it needs — a core pattern for keeping components self-contained:

# src/graphql/fragments/post.graphql
fragment PostCard on Post {
  id
  title
  slug
  excerpt
  publishedAt
  views
  commentCount
  author {
    name
    avatar
  }
  tags {
    tag {
      name
      slug
    }
  }
}
 
fragment PostDetail on Post {
  ...PostCard
  content
  comments {
    id
    content
    createdAt
    author {
      name
      avatar
    }
  }
}
# Use fragments in queries
query GetPosts($page: Int) {
  posts(page: $page) {
    posts {
      ...PostCard
    }
    total
  }
}
 
query GetPost($slug: String!) {
  post(slug: $slug) {
    ...PostDetail
  }
}

Code Generator generates the fragment types and composes them correctly. When PostCard adds a field, every query using it gets the field automatically.

Performance: N+1 with DataLoader

A common GraphQL performance trap — resolving author for 20 posts fires 20 separate database queries:

// ❌ N+1 problem
Post: {
  author: (post: Post, _, ctx: GraphQLContext) => {
    return ctx.db.user.findUnique({ where: { id: post.authorId } })
    // Fires for every post in the list
  }
}

Fix with DataLoader — batches multiple lookups into a single query:

npm install dataloader
// src/graphql/loaders.ts
import DataLoader from 'dataloader'
import { db } from '../lib/db'
import type { User } from '@prisma/client'
 
export function createUserLoader() {
  return new DataLoader<string, User | null>(async (userIds) => {
    const users = await db.user.findMany({
      where: { id: { in: [...userIds] } }
    })
    const userMap = new Map(users.map(u => [u.id, u]))
    return userIds.map(id => userMap.get(id) ?? null)
  })
}
 
// Add to context
export async function createContext({ req }: { req: Request }): Promise<GraphQLContext> {
  return {
    db,
    user: await getUserFromToken(req),
    loaders: {
      user: createUserLoader()
    }
  }
}
 
// Use in resolver
Post: {
  author: (post: Post, _, ctx: GraphQLContext) => {
    return ctx.loaders.user.load(post.authorId)
    // 20 posts → 1 DB query, not 20
  }
}

DataLoader batches all load() calls that happen within the same tick into a single batch query. Combined with the Prisma patterns for selecting only necessary fields, this keeps GraphQL APIs fast even under load.

Apollo Client vs urql vs React Query

If you're evaluating client libraries:

Apollo Client 4urqlReact Query + fetch
Bundle size~46KB~10KB~13KB
CacheNormalized (powerful)Document (simpler)No normalization
SubscriptionsManual
Code generator✅ Full✅ FullPartial
Learning curveHighMediumLow
Best forComplex caching needsSimpler appsAlready using RQ

Apollo Client's normalized cache is its biggest advantage — it deduplicates data across queries and updates all components when shared data changes. For apps with complex interconnected data, that's worth the bundle size and learning curve. For simpler apps, urql's smaller footprint and simpler mental model are attractive.

If you're already using TanStack Query for REST endpoints and only need GraphQL for one part of the app, you can use TanStack Query with raw fetch + Code Generator's typed documents instead of Apollo Client.

#graphql#typescript#nextjs#apollo#apis
Share:
C
Carlos Oliva
Software Developer · stacknotice.com

Software developer with hands-on experience building production apps with React, Next.js, Angular, TypeScript, and Spring Boot. I write practical guides on Claude Code, AI tools, and modern web development — covering the decisions and trade-offs that senior-level tutorials actually explain.

More about Carlos

Enjoyed this article?

Get weekly insights on Claude Code, React, and AI tools — practical guides for developers who build real things.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.