GraphQL Modern Guide 2026 — Build APIs with Apollo, Pothos, and React Query

Sanjeev SharmaSanjeev Sharma
5 min read

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 formatError to 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-codegen for 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
  • formatError should 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 gqlFetch wrapper provides excellent DX without a heavy client library

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading