Back to Blog
Web Development11 min readNovember 5, 2024

GraphQL API Design for Production: Patterns That Scale

How to design and deploy a production GraphQL API with Next.js and Apollo Server. Covers schema design, N+1 problem, DataLoader, persisted queries, and security hardening.

GraphQLApolloNext.jsAPITypeScript
A

Azam

DevOps & AI Consultant

When GraphQL Beats REST

GraphQL is not the right choice for every API. It excels when clients have diverse data requirements — a mobile app that fetches a subset of what the web app needs, or a dashboard that aggregates data from multiple domain objects in one request. For simple CRUD APIs with one client, REST is simpler to build and operate. For APIs consumed by multiple clients with different data shapes, GraphQL eliminates the over-fetching and under-fetching that drives REST API versioning.

Schema Design Principles

Design your schema around the client's needs, not your database shape. A good GraphQL schema feels like a product interface, not a database view.

type Query {
  user(id: ID!): User
  posts(filter: PostFilter, pagination: Pagination): PostConnection!
}

type User {
  id: ID!
  name: String!
  email: String!
  posts(first: Int, after: String): PostConnection!
  stats: UserStats!
}

type PostConnection {
  edges: [PostEdge!]!
  pageInfo: PageInfo!
  totalCount: Int!
}

type PostEdge {
  node: Post!
  cursor: String!
}

Use Relay-style pagination (Connection, Edge, PageInfo) for all list fields from the start. Retrofitting cursor-based pagination onto an offset-based API is painful. !) (non-null) on output types commits you to always returning a value — be conservative with it on fields that could legitimately be absent.

Solving the N+1 Problem with DataLoader

The N+1 problem is the most common GraphQL performance issue. A query for 10 posts that each resolve their author triggers 10 separate database calls for authors. DataLoader batches these into a single query.

import DataLoader from 'dataloader'

function createUserLoader(db: Database) {
  return new DataLoader(async (userIds) => {
    const users = await db.users.findMany({
      where: { id: { in: userIds as string[] } },
    })
    // DataLoader requires results in the same order as keys
    const userMap = new Map(users.map(u => [u.id, u]))
    return userIds.map(id => userMap.get(id) ?? null)
  })
}

// In your context factory — one loader per request
export function createContext({ req }: { req: Request }) {
  return {
    loaders: {
      user: createUserLoader(db),
    },
  }
}

// In your resolver
const Post = {
  author: (post: Post, _args: unknown, ctx: Context) =>
    ctx.loaders.user.load(post.authorId),
}

Always create DataLoader instances per-request, not per-application. A request-scoped loader prevents cross-request cache pollution and ensures authorisation checks are applied correctly per user.

Apollo Server in Next.js App Router

// app/api/graphql/route.ts
import { ApolloServer } from '@apollo/server'
import { startServerAndCreateNextHandler } from '@as-integrations/next'
import { typeDefs } from './schema'
import { resolvers } from './resolvers'
import { createContext } from './context'

const server = new ApolloServer({ typeDefs, resolvers })

const handler = startServerAndCreateNextHandler(server, {
  context: async (req) => createContext({ req }),
})

export { handler as GET, handler as POST }

Persisted Queries for Production

In production, switch from arbitrary query strings to persisted queries — a pre-registered map of query hash to query document. This eliminates the risk of clients sending malicious or unbounded queries, reduces request payload size, and enables CDN caching of GET-based queries.

// Generate persisted query manifest during build
// Client sends: { id: "sha256-abc123" } instead of full query string
// Server looks up the query from its registered map

const persistedQueriesPlugin = createPersistedQueryLink({
  useGETForHashedQueries: true, // enables CDN caching
})

Security Hardening

  • Query depth limiting: Limit nesting depth to prevent deeply nested queries that cause exponential resolver calls
  • Query complexity: Assign cost scores to fields and reject queries above a complexity threshold
  • Disable introspection in production: Introspection exposes your full schema to attackers
  • Rate limiting per operation: Apply different rate limits to expensive queries (reporting, search) vs cheap ones (profile fetch)
const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  plugins: [
    ApolloServerPluginLandingPageDisabled(),
    createComplexityPlugin({ maxComplexity: 200 }),
  ],
})

Want to Build This for Your Team?

I help teams implement the patterns and architectures described in these articles. Let's talk about your project.

Book a Free Call