← All posts
·16 min read

Claude Code with GraphQL: Schema, Resolvers, Performance

Claude CodeGraphQLAPIWorkflow
Claude Code with GraphQL: Schema, Resolvers, Performance

Why GraphQL needs explicit Claude Code rules

GraphQL is one of the higher-risk targets for AI-generated code. The reason is that the surface area looks small (a schema, some resolvers, a client) but the failure modes are silent. A resolver that fetches the right data with the wrong loading pattern looks identical to a correct one in code review. The N+1 query only appears under load. A resolver signature that diverges from the generated types compiles fine until a frontend consumer actually queries the field.

Claude Code can ship excellent GraphQL code, but only if the project tells it which patterns are mandatory. Without rules, Claude defaults to whatever is most common in the training data, which is usually schema-first Apollo Server with naive resolvers and no DataLoader. That produces working code that scales linearly with the database round trips. The fix is configuration, not better prompts.

This guide covers the CLAUDE.md rules, resolver patterns, and codegen wiring that turn Claude Code into a reliable GraphQL collaborator. The structural assumptions are TypeScript on the server, a relational database behind it, and a typed client on the frontend. If you have not configured Claude Code yet, the Claude Code setup guide covers the install and authentication before any of this applies.

The GraphQL CLAUDE.md template

Before a single resolver is written, the CLAUDE.md needs to encode the architectural decisions that downstream code depends on: schema-first or code-first, server library, resolver location, type generation flow, and the hard rules around N+1 and signature drift.

# GraphQL project rules

## Stack
- Node: 20.x
- TypeScript: 5.x
- Server: Apollo Server 4 with @apollo/server
- Schema approach: code-first via GraphQL Pothos (schema is generated from builder)
- Generated SDL: schema.graphql (auto-generated, do not hand-edit)
- Codegen: @graphql-codegen/cli for client and server types
- Database: PostgreSQL 16 via Prisma client (singleton in lib/prisma.ts)
- DataLoader: dataloader package, one instance per request in context

## Project structure
- Schema builder: src/schema/builder.ts (Pothos SchemaBuilder)
- Types: src/schema/types/ (one file per object type)
- Queries: src/schema/queries/ (one file per query field)
- Mutations: src/schema/mutations/ (one file per mutation field)
- Resolvers are co-located with the type/field they belong to
- Context: src/context.ts (request-scoped, holds prisma + loaders)
- Loaders: src/loaders/ (one file per batch loader)
- Generated types: src/generated/graphql.ts (codegen output, do not hand-edit)

## Hard rules
- NEVER write a resolver that calls prisma directly inside a loop or list resolver
- NEVER hand-edit src/generated/graphql.ts or schema.graphql
- ALWAYS use a DataLoader for any field that resolves a relation on a list type
- ALWAYS run `npm run codegen` after changing the schema before writing client code
- ALWAYS type resolver arguments and return values from the generated types
- NEVER inline string-typed resolver signatures (no `args: any`, no `return Promise<any>`)

Three things in this template do most of the work.

The code-first declaration removes ambiguity about where the schema lives. Without it, Claude will sometimes write a .graphql SDL file by hand, sometimes use gql template literals inline, and sometimes use a builder. Each approach has its own failure mode. Locking to one (Pothos in this case) means every new type follows the same pattern.

The DataLoader rule is the single most valuable line. Claude knows what DataLoader is, but when generating a resolver in isolation it tends to choose the simpler prisma.findMany call. Stating the rule explicitly converts DataLoader from "thing Claude might use" to "thing Claude must use for relations on lists."

The codegen rule prevents the most common drift bug: a resolver written before codegen has run, using whatever types Claude inferred or invented. The output usually looks plausible but the field names diverge from the generated types by the time a frontend tries to consume the API. Forcing codegen before client code is written keeps both sides on the same contract.

Schema design: code-first vs schema-first

This is the architectural decision Claude Code is most likely to get wrong if not constrained. Both approaches work. They produce different failure modes when used with an AI agent.

Schema-first (writing SDL by hand or in gql literals, then implementing resolvers) puts the schema in plain text. Claude can read and extend it easily. The risk is that the resolvers are typed separately, usually via codegen, and the generated types lag behind hand-edits. Claude will often write a resolver against a slightly outdated type, ship it, and the runtime mismatch only appears when a query hits the field. The schema looks correct, the resolver looks correct, the integration is broken.

Code-first (defining the schema via a builder like Pothos, Nexus, or TypeGraphQL) generates the SDL from typed code. The resolver and the schema cannot diverge because they are the same artifact. The downside is more boilerplate in the type definitions and a slightly steeper learning curve. The upside for Claude Code is significant: every resolver Claude writes is already type-checked against the schema definition because they share the same builder instance.

Recommendation: code-first with Pothos for new projects. Pothos has the cleanest TypeScript inference, the best plugin ecosystem (Prisma plugin, DataLoader plugin, relay plugin), and the strictest type safety. Claude Code generates better code against Pothos than against any other GraphQL library because the inference is good enough to surface mistakes at edit time rather than runtime.

A minimal Pothos type definition looks like this:

import SchemaBuilder from '@pothos/core'
import PrismaPlugin from '@pothos/plugin-prisma'
import type PrismaTypes from './generated/pothos-types'
import { prisma } from '../lib/prisma'

export const builder = new SchemaBuilder<{
  PrismaTypes: PrismaTypes
  Context: { prisma: typeof prisma; loaders: Loaders }
}>({
  plugins: [PrismaPlugin],
  prisma: { client: prisma },
})

builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    name: t.exposeString('name', { nullable: true }),
    posts: t.relation('posts'),
  }),
})

builder.prismaObject('Post', {
  fields: (t) => ({
    id: t.exposeID('id'),
    title: t.exposeString('title'),
    content: t.exposeString('content', { nullable: true }),
    author: t.relation('author'),
  }),
})

The Pothos Prisma plugin handles the relation loading automatically. t.relation('posts') produces a resolver that uses Prisma's nested select to batch the relation load, eliminating the N+1 entirely. This is the single most valuable feature for AI-assisted development: Claude can generate type definitions like this all day and they are correct by construction, because the plugin enforces the loading pattern at the schema level.

For the Prisma side of this stack, the Claude Code Prisma guide covers the schema, migration, and seeding rules that pair with this Pothos setup.

Resolver patterns and DataLoader

Once the project moves beyond the Prisma-plugin happy path, resolvers need DataLoader. Any time a field resolves a non-trivial relation, an external API call, or a derived value that depends on the parent object, you need a batched loader to avoid the N+1.

The N+1 in GraphQL looks like this. A query asks for a list of 50 users with their posts. Without a loader, the server runs SELECT * FROM users LIMIT 50 (1 query), then for each user runs SELECT * FROM posts WHERE author_id = ? (50 queries). With a loader, the same request runs 1 query for users and 1 batched query for all posts, total 2.

Claude will write the unbatched version by default unless told not to. Add this section to CLAUDE.md:

## DataLoader rules

### When to use a loader
- Any field that resolves a relation on a list type
- Any field that calls an external API per-row
- Any field that does a database lookup keyed by a foreign field

### Loader pattern
- One file per loader in src/loaders/
- Loader is created per-request and attached to context
- Loader keys are typed (number for IDs, string for slugs, etc.)
- Loader returns are typed, never `any`

### Loader file template
import DataLoader from 'dataloader'
import { prisma } from '../lib/prisma'
import type { Post } from '@prisma/client'

export const createPostsByAuthorLoader = () =>
  new DataLoader<number, Post[]>(async (authorIds) => {
    const posts = await prisma.post.findMany({
      where: { authorId: { in: [...authorIds] } },
    })
    const byAuthor = new Map<number, Post[]>()
    for (const id of authorIds) byAuthor.set(id, [])
    for (const post of posts) {
      byAuthor.get(post.authorId)!.push(post)
    }
    return authorIds.map((id) => byAuthor.get(id) ?? [])
  })

The shape of the loader function matters and Claude often gets it slightly wrong. The DataLoader contract is: given an array of keys, return an array of results in the same order, one result per key. The result for a missing key must be null, an empty array, or an Error, depending on the field cardinality. Returning a different length array breaks DataLoader silently and produces the wrong data on subsequent requests.

The pattern in the template above (build a Map, then map keys to results) is the safe pattern. It guarantees the output array matches the input keys in length and order. Claude generates this correctly when the template is in CLAUDE.md and incorrectly about half the time when generating from memory.

The loader is then attached per-request:

import { createPostsByAuthorLoader } from './loaders/postsByAuthor'
import { createUserByIdLoader } from './loaders/userById'

export interface Context {
  prisma: typeof prisma
  loaders: {
    postsByAuthor: ReturnType<typeof createPostsByAuthorLoader>
    userById: ReturnType<typeof createUserByIdLoader>
  }
}

export const createContext = (): Context => ({
  prisma,
  loaders: {
    postsByAuthor: createPostsByAuthorLoader(),
    userById: createUserByIdLoader(),
  },
})

The "per-request" detail is non-negotiable. A loader instance caches results for its lifetime. If you create one loader at startup and reuse it across requests, you get stale data and cross-request data leaks. Creating loaders inside createContext and passing the function reference to Apollo Server (context: createContext) gives every request a fresh set.

In a resolver, the loader replaces the direct Prisma call:

builder.prismaObject('User', {
  fields: (t) => ({
    id: t.exposeID('id'),
    email: t.exposeString('email'),
    posts: t.field({
      type: ['Post'],
      resolve: (user, _args, ctx) => ctx.loaders.postsByAuthor.load(user.id),
    }),
  }),
})

This is the pattern that scales. A query for 1,000 users with their posts triggers exactly 2 database round trips: one for users, one for the batched post lookup keyed on the 1,000 author IDs. Without DataLoader, the same query runs 1,001 round trips and times out under any real load.

The trade-off worth knowing: the Pothos Prisma plugin handles the common relation cases (t.relation('posts')) without an explicit loader, because it uses Prisma's nested select to fetch relations in the same query. DataLoader is needed when the relation crosses a boundary the plugin cannot see: external APIs, computed fields, cross-database joins, anything not in the Prisma schema.

Apollo Server, Yoga, and Pothos: which stack and why

The server library choice shapes everything downstream. There are three credible options in 2026 and Claude Code performs differently against each.

Apollo Server 4 is the default choice and the one Claude Code knows best. The training data is heavily weighted toward Apollo. The integration with Express, Fastify, and standalone HTTP is mature. The plugin ecosystem (federation, persisted queries, response caching) is the deepest. For most projects, Apollo Server with Pothos for the schema is the path of least surprise.

GraphQL Yoga (from The Guild) is the modern alternative. Smaller core, faster cold start, better defaults for streaming and subscriptions, built-in support for file uploads and persisted queries without a plugin. Yoga is what you choose when you care about edge runtime compatibility (Cloudflare Workers, Vercel Edge) or when Apollo's bundle size is a problem. Claude Code generates correct Yoga code as long as the CLAUDE.md specifies it explicitly, otherwise it defaults to Apollo patterns and the imports break.

Pothos is not a server, it is a schema builder. It sits in front of Apollo or Yoga and produces a GraphQLSchema instance that either server can mount. The Pothos plugin ecosystem (@pothos/plugin-prisma, @pothos/plugin-dataloader, @pothos/plugin-relay, @pothos/plugin-errors) is the strongest argument for the code-first approach.

A typical startup file ties them together:

import { ApolloServer } from '@apollo/server'
import { startStandaloneServer } from '@apollo/server/standalone'
import { builder } from './schema/builder'
import { createContext, type Context } from './context'

import './schema/types/User'
import './schema/types/Post'
import './schema/queries/users'
import './schema/queries/posts'
import './schema/mutations/createPost'

const schema = builder.toSchema()

const server = new ApolloServer<Context>({ schema })

const { url } = await startStandaloneServer(server, {
  context: async () => createContext(),
  listen: { port: 4000 },
})

console.log(`GraphQL ready at ${url}`)

The side-effect imports of the type and resolver files are deliberate. Each file calls builder.prismaObject(...) or builder.queryField(...) at import time, which registers the type with the builder. Without those imports, the schema is empty. This pattern is fragile if Claude is generating new types, because Claude often writes the type file but forgets to add the import to the entry point. Add a rule to CLAUDE.md: "every new file in src/schema/ must be imported in src/index.ts before the schema is built." Catches this consistently.

Codegen workflow with graphql-codegen

Type generation is what stops resolver signature drift. Without codegen, Claude writes resolvers with hand-rolled types that diverge from the schema. With codegen, every resolver argument and return type is derived from the schema, and the build fails if Claude invents a field that does not exist.

The setup is a single config file:

import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  schema: 'http://localhost:4000/graphql',
  documents: ['src/**/*.{ts,tsx}', '!src/generated/**/*'],
  generates: {
    'src/generated/graphql.ts': {
      plugins: [
        'typescript',
        'typescript-operations',
        'typescript-resolvers',
      ],
      config: {
        contextType: '../context#Context',
        useTypeImports: true,
        avoidOptionals: true,
        defaultScalarType: 'unknown',
      },
    },
  },
}

export default config

The package.json scripts that wire this in:

{
  "scripts": {
    "codegen": "graphql-codegen",
    "codegen:watch": "graphql-codegen --watch",
    "build": "npm run codegen && tsc",
    "dev": "npm run codegen:watch & tsx watch src/index.ts"
  }
}

For projects using Pothos, you usually do not need typescript-resolvers because Pothos enforces resolver shapes through its builder. You still want typescript and typescript-operations for the client side: every .tsx file that contains a graphql(...) call gets a fully typed query result and variables type. This is the leverage point for frontend code.

Add to CLAUDE.md:

## Codegen workflow

### Run order for any schema change
1. Edit src/schema/* (types, queries, mutations)
2. Restart the dev server (Pothos rebuilds the schema)
3. Run `npm run codegen` (regenerates src/generated/graphql.ts)
4. Write or update client code, importing types from src/generated/graphql.ts
5. Run `npm run build` to confirm the schema and clients align

### Generated file rules
- src/generated/graphql.ts: never hand-edit, regenerate via codegen
- schema.graphql: auto-generated by Pothos via `builder.toSchema()`, then dumped via printSchema
- Always commit generated files to git (so CI does not need a running server)

The "commit generated files" rule is debated in the GraphQL community. The argument for: CI does not need a live GraphQL server to type-check the client. The argument against: merge conflicts in generated files. For Claude Code projects the trade-off favours committing them: Claude reads the generated types when writing client queries, and a missing or stale graphql.ts produces silently broken code. Commit them and resolve merge conflicts by regenerating.

Client-side patterns: Apollo Client and urql

The frontend is where typed GraphQL pays off most visibly. Every query result, every variable, every mutation response is fully typed end-to-end. Claude Code is excellent at writing client queries when the codegen output is available.

Two clients are worth using: Apollo Client (mature, biggest ecosystem) or urql (smaller, faster, better cache primitives). Claude generates correct code for both. Apollo Client is the default choice for most applications. urql is the choice when you want a smaller bundle, simpler cache semantics, or better SvelteKit/Solid integration.

A typed Apollo Client query looks like this:

import { gql, useQuery } from '@apollo/client'
import type {
  GetUserQuery,
  GetUserQueryVariables,
} from '../generated/graphql'

const GET_USER = gql`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      email
      name
      posts {
        id
        title
      }
    }
  }
`

export function UserProfile({ userId }: { userId: string }) {
  const { data, loading, error } = useQuery<GetUserQuery, GetUserQueryVariables>(
    GET_USER,
    { variables: { id: userId } }
  )

  if (loading) return <div>Loading</div>
  if (error) return <div>Error: {error.message}</div>
  if (!data?.user) return <div>Not found</div>

  return (
    <div>
      <h1>{data.user.name ?? data.user.email}</h1>
      <ul>
        {data.user.posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

The two type imports (GetUserQuery and GetUserQueryVariables) come from codegen. They are derived from the actual GET_USER document parsed against the running schema. If the schema does not have a posts field on User, the build fails. If the query asks for a field that does not exist, the build fails. Claude cannot hallucinate a query that references a non-existent field because the type system rejects it.

The urql equivalent is similar with smaller surface area:

import { useQuery } from 'urql'
import { graphql } from '../generated/gql'

const GetUserDocument = graphql(`
  query GetUser($id: ID!) {
    user(id: $id) {
      id
      email
      name
      posts {
        id
        title
      }
    }
  }
`)

export function UserProfile({ userId }: { userId: string }) {
  const [{ data, fetching, error }] = useQuery({
    query: GetUserDocument,
    variables: { id: userId },
  })

  if (fetching) return <div>Loading</div>
  if (error) return <div>Error: {error.message}</div>
  return <div>{data?.user?.name}</div>
}

The graphql() helper from @graphql-codegen/client-preset is what urql (and modern Apollo) recommend. It returns a fully typed document directly, no separate type imports needed. The CLAUDE.md rule that pays off here:

## Client query rules
- Use the `graphql()` helper for all client queries (typed documents)
- Do not import types from src/generated/graphql.ts directly in component files
- Place query documents next to the component that uses them
- Name queries with a verb prefix: GetUser, ListPosts, CreateComment, UpdateProfile

The naming rule prevents the "untitled query" problem: when codegen sees an anonymous query, the generated type is Unnamed_QueryQuery or similar. Naming every query produces clean type names that Claude can reference confidently in subsequent code.

For broader TypeScript patterns that this builds on, the Claude Code TypeScript guide covers strict mode, type inference, and the configuration that makes generated types useful.

Hard rules and what to verify

The CLAUDE.md template earlier in this guide encodes the rules. The verification checklist is what you run before merging Claude-generated GraphQL code.

N+1 audit on any new resolver. If a new resolver returns a list, or adds a field to a type that is commonly returned in lists, run the query against a seeded development database and watch the SQL log. A query that returns N rows should fire 1 query, not N+1. If you see N+1, add a DataLoader. The Pothos Prisma plugin catches this for relation fields, but anything outside the plugin's scope needs manual verification. The Claude Code debugging guide covers the broader patterns for tracing queries and isolating performance regressions in AI-generated code.

Resolver signature against generated types. After codegen, every resolver arg and return should match the type signatures in src/generated/graphql.ts. The TypeScript compiler enforces this at build time, but Claude sometimes generates code that uses as any or inline type assertions to silence errors. Grep for as any in resolver files. Each one is a potential signature drift bug.

Schema additions in the entry point. Every new file in src/schema/ must be imported in src/index.ts (or wherever the schema is built) before builder.toSchema() is called. A type defined but not imported is invisible in the schema and produces a confusing "field does not exist" error at query time. Run npm run dev and query the new field once before committing.

Codegen output committed. After any schema change, npm run codegen must be run and the output committed alongside the schema change. A PR that changes the schema without updating src/generated/graphql.ts will break CI for any consumer.

Per-request loader instances. Every loader in the context object should be created inside createContext(), not imported as a singleton. A singleton loader leaks data across requests because DataLoader's cache is the request scope. This is a class of bug Claude can introduce when refactoring loader code, and it is easy to miss in code review because the symptom (occasional stale data) does not appear in tests.

For the broader practice of writing rules into CLAUDE.md so they apply automatically, the CLAUDE.md explained guide covers the file's structure, the patterns that work, and the ones that do not. For the broader workflow practices that complement these rules, the Claude Code best practices guide collects the patterns that pay off across any project.

Putting it together

A GraphQL project configured along these lines produces Claude Code output that is hard to distinguish from a senior engineer's work. The schema is code-first via Pothos, with typed relations and the Prisma plugin handling the common N+1 cases automatically. Resolvers outside the plugin's scope use DataLoader, with per-request instances and typed key/result contracts. Codegen runs on every schema change, generating types that the resolver and client code both depend on. The frontend uses the graphql() helper to get fully typed documents that fail at build time if the schema diverges.

The configuration is the leverage. Without CLAUDE.md, Claude generates working GraphQL code that scales linearly with the database, drifts from the schema over time, and produces hallucinated resolver signatures that compile but break at runtime. With CLAUDE.md, the same prompts produce code that is type-safe end to end, batched at every list boundary, and aligned with the schema by construction.

Start with the CLAUDE.md template at the top of this guide. Wire up Pothos with the Prisma plugin if you are starting fresh, or add the DataLoader rules to your existing Apollo setup. Run codegen on every schema change. Commit the generated files. The first week of disciplined use turns Claude into a GraphQL collaborator that ships safer code than most teams produce by hand. Claudify includes a GraphQL-specific CLAUDE.md template as part of the Claude Code workflow kit, with the Pothos builder, DataLoader patterns, and codegen wiring pre-configured for TypeScript projects.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir