DEV Community

Carlos Oliva Pascual
Carlos Oliva Pascual

Posted on • Originally published at stacknotice.com

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.

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

Top comments (0)