Claude Code with Apollo GraphQL: Schema-First API Design
Why Apollo without CLAUDE.md generates resolvers that ignore the schema contract
Apollo GraphQL enforces a contract. The schema defines every type, every field, every nullable boundary. Every resolver must honor that contract: return the type the schema declares, populate the fields the client can request, and never silently return a shape that does not match the type definition. When the contract holds, the entire graph is predictable. When it breaks, clients receive null fields they did not expect, runtime errors that the type system should have caught, and performance regressions that only surface under production load.
Claude Code does not know the contract exists unless you tell it. Without a CLAUDE.md that declares the schema as the source of truth, Claude generates resolvers from the TypeScript model or the database schema it can see in the repository. It builds the shape it infers, not the shape the SDL declares. A User type in the schema might declare posts: [Post!]! as a required field. Claude generates a resolver that returns the user object from the database and leaves posts unresolved, because the database model has no posts field. The client query succeeds at the schema level and returns null for every posts field. No error, no warning, just missing data.
The N+1 problem follows immediately. Claude generates a resolver for User.posts that calls db.posts.findMany({ where: { userId: user.id } }). That resolver is called once per user in the list. A query that returns 50 users triggers 51 database calls: one for the user list, fifty for the posts. Without a DataLoader constraint in CLAUDE.md, Claude does not add batching because batching is not the obvious naive implementation.
The Apollo Client side has its own failure mode. InMemoryCache normalizes objects by __typename + id. Without typePolicies, paginated lists overwrite on every fetch instead of merging. A query that fetches page 2 replaces page 1 in the cache. Infinite scroll breaks. Claude generates the ApolloClient instantiation without typePolicies because the default behavior is technically correct for non-paginated data, and Claude has no way to know the query uses pagination.
This guide covers the CLAUDE.md configuration that anchors Claude Code to Apollo's actual model: schema-first SDL, resolvers that match the declared types, DataLoader that batches by default, Apollo Client cache policies that handle pagination, and graphql-codegen that keeps TypeScript types in sync with the schema. For the foundational setup before adding Apollo, the Claude Code setup guide covers installation. For the TypeScript conventions that underpin a schema-first codebase, Claude Code with TypeScript is worth reading alongside this guide.
The Apollo CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every session. For an Apollo GraphQL project it needs to declare: server version and transport, schema file location, resolver structure, context shape, DataLoader requirements, Apollo Client cache configuration, codegen config, federation setup if applicable, and the hard rules that block the patterns Claude generates most often without guidance.
# Apollo GraphQL rules
## Stack
- Apollo Server v4.x (@apollo/server)
- Apollo Client v3.x
- TypeScript 5.x strict mode
- graphql-codegen with TypeScript + TypeScript-Resolvers plugins
- Node.js 20.x
## Schema
- Schema lives in src/schema/*.graphql (SDL files, not JS template literals)
- Single entry point: src/schema/index.ts imports and merges all .graphql files
- Schema is the source of truth: resolvers must match it exactly
- NEVER define typeDefs as a JS template literal (gql`...`) in production code
- All type definitions MUST use .graphql files for tooling support and syntax highlighting
## Server setup (Apollo Server v4)
- Import: import { ApolloServer } from '@apollo/server'
- Standalone: import { startStandaloneServer } from '@apollo/server/standalone'
- Express integration: import { expressMiddleware } from '@apollo/server/express4'
- NEVER import from 'apollo-server' or 'apollo-server-express' (v3, deprecated)
- ALWAYS call startStandaloneServer() or expressMiddleware(), bare new ApolloServer() does not listen
- Server entry: src/server.ts
## Resolver structure
- Resolvers in src/resolvers/ (one file per type: Query.ts, Mutation.ts, User.ts, Post.ts)
- Root file: src/resolvers/index.ts merges all resolver maps
- Resolver signature: (parent, args, context: Context, info) => ReturnType
- Context shape is declared in src/types/context.ts, always typed
- NEVER use 'any' for resolver return types. Use generated types from codegen
## Context
- Per-request context factory: async ({ req }) => Context
- Context always includes: { user: AuthenticatedUser | null, dataSources, db }
- dataSources contains DataLoader instances (not raw db calls)
- Auth is resolved in context factory, never inside individual resolvers
- Context type: import { Context } from '../types/context'
## DataLoader (N+1 prevention, MANDATORY)
- EVERY resolver that fetches related entities MUST use a DataLoader
- DataLoaders live in src/dataloaders/ (one file per entity)
- DataLoader signature: new DataLoader<string, Entity>(async (ids) => batchFn(ids))
- Batch function MUST return results in the same order as input ids
- DataLoaders are instantiated per request in the context factory, not as singletons
- NEVER call db.entity.findMany({ where: { id: parentId } }) inside a field resolver
- ALWAYS use context.dataSources.entityLoader.load(parentId) instead
## Apollo Client (v3)
- Client instantiation in src/lib/apolloClient.ts
- Always use InMemoryCache with explicit typePolicies
- typePolicies MUST be defined for any query that uses pagination or cursor-based fetching
- keyFields overrides for types without standard 'id' field
- NEVER instantiate ApolloClient without a typePolicies object, even if empty
## graphql-codegen
- Config: codegen.ts at project root
- Generates: src/generated/graphql.ts (types) and src/generated/hooks.ts (React hooks)
- Run: npm run codegen (add to pre-commit hook)
- ALWAYS import types from src/generated/graphql.ts, not manually declared
- NEVER manually declare GraphQL operation types. Codegen owns them
## Federation (if applicable)
- Subgraph: import { buildSubgraphSchema } from '@apollo/subgraph'
- Gateway: @apollo/gateway with managed federation via Apollo Studio
- Each subgraph MUST define @key directive on all entity types
- Subgraph resolvers MUST implement __resolveReference for all @key entities
## Hard rules
- NEVER import from 'apollo-server' or 'apollo-server-express'
- NEVER define typeDefs in JS template literals in .ts files
- NEVER write a field resolver that calls db directly (always DataLoader)
- NEVER return a type that differs from the schema declaration
- NEVER forget to call startStandaloneServer(). Without it the server will not listen
- ALWAYS run codegen after schema changes before writing resolvers
- ALWAYS define typePolicies on InMemoryCache for paginated queries
- ALWAYS batch with DataLoader, never solve N+1 with a single join query inside a resolver
Four rules here prevent the most expensive mistakes Claude makes on Apollo projects without them.
The never-import-from-apollo-server rule is the most immediately breaking. Apollo Server v3 (the apollo-server and apollo-server-express packages) has a fundamentally different API from v4 (@apollo/server). Claude was trained on both and defaults to the v3 import pattern for Express integrations because it has seen more of those examples. The v3 packages are deprecated and will not receive security updates. The rule forces the correct v4 import path every time.
The never-define-typeDefs-in-JS-template-literals rule matters for tooling. A gql template literal in a .ts file is opaque to GraphQL language servers, schema validators, and codegen tools. The .graphql file approach gives you syntax highlighting, autocomplete, schema validation on save, and the ability to run graphql-inspector to detect breaking changes. Claude generates const typeDefs = gql\...`` because it is the quickest way to show a working example. The rule redirects it to the SDL file approach, which is correct for any project larger than a demo.
The mandatory-DataLoader rule prevents a category of production incidents. Without it, Claude generates resolvers that are logically correct but catastrophically slow under load. A posts query that returns 100 posts, each with a User author, triggers 101 database queries. With DataLoader, that collapses to 2.
The startStandaloneServer rule prevents a subtle boot failure. new ApolloServer({ typeDefs, resolvers }) creates the server object but does not start listening. startStandaloneServer(server, { listen: { port: 4000 } }) is the required second call. Claude sometimes generates only the constructor call, producing a server process that starts and immediately exits or hangs without accepting connections.
Apollo Server v4 setup and context
The server setup is more explicit in v4 than in v3. The startStandaloneServer function handles the HTTP layer. expressMiddleware handles the Express integration. Both require the context factory as a second argument.
Add a server setup section to CLAUDE.md:
## Server setup patterns (src/server.ts)
### Standalone (no Express)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const server = new ApolloServer({ typeDefs, resolvers });
const { url } = await startStandaloneServer(server, {
listen: { port: 4000 },
context: createContext,
});
console.log(`Server ready at ${url}`);
### Express integration
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
import express from 'express';
import cors from 'cors';
import { json } from 'body-parser';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
import { createContext } from './context';
const app = express();
const server = new ApolloServer({ typeDefs, resolvers });
await server.start(); // REQUIRED before expressMiddleware()
app.use(
'/graphql',
cors(),
json(),
expressMiddleware(server, { context: createContext }),
);
app.listen(4000, () => console.log('Server ready at http://localhost:4000/graphql'));
### Context factory (src/context.ts)
import { StandaloneServerContextFunctionArgument } from '@apollo/server/standalone';
import { authenticate } from './auth';
import { createDataSources } from './dataloaders';
import { db } from './db';
export type Context = {
user: AuthenticatedUser | null;
dataSources: ReturnType<typeof createDataSources>;
db: typeof db;
};
export async function createContext({
req,
}: StandaloneServerContextFunctionArgument): Promise<Context> {
const user = await authenticate(req);
return {
user,
dataSources: createDataSources(),
db,
};
}
## Rules
- ALWAYS await server.start() before attaching expressMiddleware
- Context factory is async. ALWAYS return a Promise<Context>
- Auth lives in context factory only, NEVER authenticate inside a resolver
- dataSources is recreated per request so DataLoaders do not bleed between requests
The await server.start() requirement before expressMiddleware is the Express-specific variant of the missing-startStandaloneServer issue. Claude generates the expressMiddleware call immediately after the constructor in some contexts, which throws at runtime because the server has not completed initialization. The explicit start() call belongs in CLAUDE.md because it is not obvious from reading the constructor call that a second initialization step exists.
The context factory pattern matters for DataLoader correctness. DataLoaders maintain an in-memory batch cache for the duration of the request. If DataLoader instances are singletons shared across requests, cache entries from request A are visible in request B, which leaks data across users. Instantiating DataLoaders inside the context factory gives each request a fresh loader with an empty cache. Claude will create DataLoaders as module-level singletons if the instantiation pattern is not explicitly defined.
Schema-first design with SDL files
Schema-first means the .graphql SDL file is written before any resolver code. The schema defines the API surface. Resolvers implement it. Codegen generates the TypeScript types. The flow is: edit schema, run codegen, implement resolvers against generated types, not: write a resolver, infer the schema from it.
Add a schema section to CLAUDE.md:
## Schema organisation (src/schema/)
### Directory layout
src/schema/
index.ts , merges all .graphql files with mergeTypeDefs
base.graphql , scalar types, directives, root type stubs
user.graphql , User type, UserQueries, UserMutations
post.graphql , Post type, PostQueries, PostMutations
pagination.graphql , Connection, Edge, PageInfo types
### base.graphql
scalar DateTime
scalar JSON
type Query
type Mutation
### user.graphql
extend type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
}
extend type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
type User {
id: ID!
email: String!
name: String!
posts: [Post!]!
createdAt: DateTime!
}
input CreateUserInput {
email: String!
name: String!
}
type CreateUserPayload {
user: User
errors: [UserError!]!
}
type UserError {
field: String
message: String!
}
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type UserEdge {
node: User!
cursor: String!
}
### index.ts
import { mergeTypeDefs } from '@graphql-tools/merge';
import { loadFilesSync } from '@graphql-tools/load-files';
import path from 'path';
export const typeDefs = mergeTypeDefs(
loadFilesSync(path.join(__dirname, '*.graphql'))
);
## Rules
- SDL files only, never gql template literals in .ts files
- One .graphql file per domain entity
- extend type Query/Mutation in entity files, never redefine root types
- Relay-style pagination (Connection/Edge/PageInfo) for all list queries
- Always declare nullable boundaries explicitly: String! vs String
- Input types for all mutation arguments (never raw args: id: ID!, name: String!)
- Payload types for all mutations (include errors array for user-facing errors)
The Relay-style Connection pattern for paginated lists is important for Apollo Client cache correctness. The PageInfo.endCursor and hasNextPage fields give Apollo Client the data it needs to implement cursor-based pagination. Combined with the typePolicies on the client, the cache can merge pages correctly instead of overwriting. Claude will generate a flat [User!]! list type for paginated queries unless the Connection pattern is declared in CLAUDE.md.
The mutation payload pattern (returning a CreateUserPayload with both user and errors instead of User | null) is a GraphQL convention that Claude does not follow by default. It makes errors explicit in the type system and avoids the pattern where a mutation succeeds at the HTTP level but fails at the business logic level, leaving the client with no indication of what went wrong beyond a null return value.
DataLoader for N+1 batching
DataLoader solves the N+1 problem by collecting all keys requested within a single event loop tick and resolving them in a single batch function call. For a query that returns 50 users each with their posts, DataLoader turns 51 database calls into 2.
Add a DataLoader section to CLAUDE.md:
## DataLoader patterns (src/dataloaders/)
### Loader factory (src/dataloaders/index.ts)
import DataLoader from 'dataloader';
import { db } from '../db';
export function createDataSources() {
return {
userLoader: createUserLoader(),
postsByUserLoader: createPostsByUserLoader(),
};
}
### User loader by ID
function createUserLoader() {
return new DataLoader<string, User | null>(async (ids) => {
const users = await db.user.findMany({
where: { id: { in: [...ids] } },
});
const userMap = new Map(users.map((u) => [u.id, u]));
// Return in same order as input ids, DataLoader requirement
return ids.map((id) => userMap.get(id) ?? null);
});
}
### Posts by user loader (one-to-many)
function createPostsByUserLoader() {
return new DataLoader<string, Post[]>(async (userIds) => {
const posts = await db.post.findMany({
where: { authorId: { in: [...userIds] } },
});
const postsByUser = new Map<string, Post[]>();
for (const post of posts) {
const existing = postsByUser.get(post.authorId) ?? [];
postsByUser.set(post.authorId, [...existing, post]);
}
// Return empty array (not undefined) for users with no posts
return userIds.map((id) => postsByUser.get(id) ?? []);
});
}
### Resolver using DataLoader (src/resolvers/User.ts)
import { UserResolvers } from '../generated/graphql';
export const User: UserResolvers = {
posts: async (parent, _args, context) => {
return context.dataSources.postsByUserLoader.load(parent.id);
},
};
## Rules
- Batch function MUST return an array of the same length as input ids
- Batch function MUST return results in the same order as input ids
- Return null (not throw) for missing single-entity lookups
- Return [] (not undefined) for missing one-to-many lookups
- NEVER call db directly inside a field resolver, always use a loader
- DataLoaders are instantiated per request in createDataSources(), never as module singletons
- Use DataLoader's second argument for cache options: { cache: false } for write-heavy contexts
The ordering requirement is the most common DataLoader bug Claude generates. The batch function receives [id1, id2, id3] and must return [result1, result2, result3] in the same positional order. A database query with where: { id: { in: ids } } returns rows in database order, not input order. The Map lookup pattern in the template resolves this correctly. Without the explicit template, Claude sometimes returns the raw database results in query order, which associates results with the wrong parent IDs and produces data corruption rather than an error.
Apollo Client v3 with InMemoryCache and typePolicies
Apollo Client v3 normalizes cached objects by __typename + id. Every User with id: "1" maps to the same cache entry, so updating a user in one query automatically updates all queries that display the same user. The problem is list fields. A paginated query that fetches page 2 has the same cache key as the query that fetched page 1, so without a merge policy, page 2 overwrites page 1.
Add an Apollo Client section to CLAUDE.md:
## Apollo Client setup (src/lib/apolloClient.ts)
### Client instantiation
import { ApolloClient, InMemoryCache, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
import { HttpLink } from '@apollo/client';
const httpLink = new HttpLink({ uri: '/graphql' });
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) {
graphQLErrors.forEach(({ message, path }) => {
console.error(`GraphQL error on ${path?.join('.')}: ${message}`);
});
}
if (networkError) console.error('Network error:', networkError);
});
export const client = new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
users: {
// Cursor pagination merge, appends pages instead of replacing
keyArgs: ['filter'], // cache separately by filter, not cursor
merge(existing, incoming) {
return {
...incoming,
edges: [...(existing?.edges ?? []), ...incoming.edges],
};
},
},
},
},
User: {
// Standard id field, default behavior, explicit for documentation
keyFields: ['id'],
},
},
}),
});
### typePolicies patterns
#### Relay cursor pagination merge
fields: {
users: {
keyArgs: ['filter', 'orderBy'],
merge(existing, incoming) {
return {
...incoming,
edges: [...(existing?.edges ?? []), ...incoming.edges],
};
},
},
}
#### Replace (not merge), for non-paginated lists that should be fresh
fields: {
featuredPosts: {
merge(_existing, incoming) {
return incoming;
},
},
}
#### Custom keyFields for types without 'id'
typePolicies: {
UserSession: {
keyFields: ['token'],
},
SearchResult: {
keyFields: ['query', 'page'],
},
}
## Rules
- ALWAYS define typePolicies for paginated queries
- keyArgs determines cache partitioning, exclude cursor/page args, include filter/sort args
- merge function receives (existing, incoming) and must return the merged shape
- NEVER omit typePolicies on InMemoryCache, even non-paginated clients should declare an empty object
- Use errorLink in the link chain, never rely solely on onError in useQuery
The keyArgs field in typePolicies is what Claude most often gets wrong. By default, Apollo caches each unique set of arguments as a separate cache entry. Including cursor or page in keyArgs means page 1 and page 2 are cached separately and never merged. Excluding them from keyArgs means both pages map to the same cache entry and the merge function is called to combine them. Claude generates keyArgs: false (cache all as one entry regardless of arguments) when it adds pagination merging at all, which breaks filtered queries because filter changes do not create a new cache entry. The explicit keyArgs: ['filter'] pattern caches per filter while merging across pages.
useQuery, useMutation, and code generation with graphql-codegen
graphql-codegen generates fully typed React hooks from your schema and your operation documents. The generated hooks include the correct return types, variable types, and refetch types for every query and mutation. This eliminates the manual type declarations that drift out of sync with the schema.
Add a codegen section to CLAUDE.md:
## graphql-codegen setup (codegen.ts)
import type { CodegenConfig } from '@graphql-codegen/cli';
const config: CodegenConfig = {
schema: 'src/schema/**/*.graphql',
documents: 'src/**/*.graphql', // client operation documents
generates: {
'src/generated/graphql.ts': {
plugins: ['typescript', 'typescript-operations'],
config: {
strictScalars: true,
scalars: {
DateTime: 'string',
JSON: 'unknown',
},
},
},
'src/generated/hooks.ts': {
preset: 'import-types',
presetConfig: { typesPath: './graphql' },
plugins: ['typescript-react-apollo'],
config: {
withHooks: true,
withComponent: false,
withHOC: false,
},
},
},
};
export default config;
### Operation documents (src/operations/)
# src/operations/users.graphql
query GetUsers($filter: UserFilter, $after: String, $first: Int) {
users(filter: $filter, after: $after, first: $first) {
edges {
node {
id
name
email
createdAt
}
cursor
}
pageInfo {
hasNextPage
endCursor
}
totalCount
}
}
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
user {
id
name
email
}
errors {
field
message
}
}
}
### Generated hook usage (after running npm run codegen)
import { useGetUsersQuery, useCreateUserMutation } from '../generated/hooks';
function UserList() {
const { data, loading, error, fetchMore } = useGetUsersQuery({
variables: { first: 20 },
});
const [createUser, { loading: creating }] = useCreateUserMutation({
update(cache, { data }) {
// Update cache after mutation, or use refetchQueries
},
onError(err) {
console.error('Mutation failed:', err);
},
});
if (loading) return <Spinner />;
if (error) return <Error message={error.message} />;
return (
<ul>
{data?.users.edges.map(({ node }) => (
<li key={node.id}>{node.name}</li>
))}
</ul>
);
}
## Rules
- ALWAYS run codegen after schema or operation changes: npm run codegen
- NEVER manually declare operation types, codegen owns src/generated/
- Operation documents live in src/operations/*.graphql, not inline in components
- useQuery + useMutation import from src/generated/hooks, not @apollo/client directly
- Add codegen to pre-commit hook so generated files are always in sync with schema
The operation-document-in-.graphql-file pattern is the codegen equivalent of the server-side SDL rule. Inline gql template literals in component files work, but codegen runs against the file system, not the compiled output. SDL operation files give codegen a stable, parseable target and keep operation logic separate from component logic. Claude generates inline gql operations by default because that is how Apollo Client examples in the docs are written. The CLAUDE.md rule redirects to SDL files and points codegen at the src/operations/ directory.
For the React patterns around data fetching that combine well with Apollo hooks, Claude Code with React covers the component conventions that keep generated hook usage clean.
Apollo Federation with @apollo/subgraph
Federation composes multiple GraphQL services into a single graph. Each service is a subgraph with its own schema. The gateway combines them. Federation v2 uses @apollo/subgraph on the subgraph side and @apollo/gateway or the Apollo Router on the gateway side.
Add a federation section to CLAUDE.md:
## Federation setup (if applicable)
### Subgraph schema (users-service/src/schema/user.graphql)
extend schema @link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key"])
type User @key(fields: "id") {
id: ID!
email: String!
name: String!
}
extend type Query {
user(id: ID!): User
users: [User!]!
}
### Subgraph server (users-service/src/server.ts)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { buildSubgraphSchema } from '@apollo/subgraph';
import { typeDefs } from './schema';
import { resolvers } from './resolvers';
const server = new ApolloServer({
schema: buildSubgraphSchema({ typeDefs, resolvers }),
});
await startStandaloneServer(server, { listen: { port: 4001 } });
### __resolveReference (MANDATORY for entity types)
// users-service/src/resolvers/User.ts
export const User = {
__resolveReference: async (reference: { id: string }, context: Context) => {
return context.dataSources.userLoader.load(reference.id);
},
posts: async (parent: User, _args: unknown, context: Context) => {
return context.dataSources.postsByUserLoader.load(parent.id);
},
};
### Gateway (gateway/src/server.ts)
import { ApolloServer } from '@apollo/server';
import { startStandaloneServer } from '@apollo/server/standalone';
import { ApolloGateway, IntrospectAndCompose } from '@apollo/gateway';
const gateway = new ApolloGateway({
supergraphSdl: new IntrospectAndCompose({
subgraphs: [
{ name: 'users', url: 'http://localhost:4001/graphql' },
{ name: 'posts', url: 'http://localhost:4002/graphql' },
],
}),
});
const server = new ApolloServer({ gateway });
await startStandaloneServer(server, { listen: { port: 4000 } });
## Rules
- buildSubgraphSchema() replaces the raw typeDefs + resolvers pair on subgraph servers
- EVERY entity type with @key MUST implement __resolveReference
- __resolveReference receives the reference object (containing @key fields) and context
- Use DataLoader inside __resolveReference, gateway may call it for many entities at once
- Gateway does not need typeDefs or resolvers, it composes from subgraphs
- Never add federation directives to a non-federated schema, they are incompatible
The __resolveReference requirement is the federation entry Claude most often omits. When the gateway receives a query that touches a User entity referenced from another subgraph, it calls __resolveReference on the users subgraph to hydrate the full entity. Without the resolver, the gateway cannot resolve cross-subgraph references and returns null for every field beyond the @key fields. Claude generates the subgraph schema with @key correctly but omits __resolveReference because the connection between the directive and the resolver is not implicit in the schema syntax.
Common Claude Code mistakes on Apollo projects
Four categories of mistakes appear consistently when Claude generates Apollo code without the CLAUDE.md template above.
The v3 import pattern. apollo-server-express and apollo-server are v3 packages. @apollo/server is v4. Claude mixes them because the npm package names look similar and the v3 API is more represented in its training data. The error surfaces at runtime, not build time: the v3 middleware function does not exist on the v4 server object. The --save-exact lockfile and the import rule in CLAUDE.md both prevent this. Always run npm ls @apollo/server to confirm the v4 package is installed before generating code.
The missing startStandaloneServer call. new ApolloServer(config) does not start a server. startStandaloneServer(server, options) does. Claude sometimes generates the constructor call and writes no further server code, producing a process that exits immediately. The CLAUDE.md rule makes the two-step startup mandatory. If you see a server process that starts and returns control immediately without logging a URL, this is the cause.
The singleton DataLoader. When DataLoaders are instantiated outside the context factory as module-level singletons, they cache request A's data and serve it to request B. For user-specific data, this is a data leak. For entity data that changes frequently, it causes stale reads. The per-request instantiation pattern in the CLAUDE.md template prevents both. If you see stale data or cross-user data leaks in a DataLoader-using application, check whether the loaders are singletons.
The absent typePolicies. A paginated query without a merge policy in typePolicies will overwrite the cache on every page fetch. The symptom is an infinite scroll implementation that always shows only the most recently fetched page, never accumulating. Claude generates new InMemoryCache() with no arguments unless the typePolicies template is in CLAUDE.md. For the Next.js-specific Apollo Client setup that includes server-side rendering with cache rehydration, Claude Code with Next.js covers the additional configuration required.
Building an Apollo GraphQL codebase that does not drift from its schema
The Apollo CLAUDE.md in this guide produces a codebase where the schema is the single source of truth, resolvers are typed against generated interfaces and cannot silently return wrong shapes, N+1 database calls are structurally impossible because field resolvers have no access to the database directly, the Apollo Client cache handles pagination without overwriting, and TypeScript types for every operation are generated rather than manually declared.
The underlying principle is the same across every framework integration with Claude Code. Without a CLAUDE.md, Claude generates code that is locally plausible but globally incorrect: resolvers that ignore the schema, client code that breaks under pagination, server imports from deprecated packages, and batch functions that return results in the wrong order. With the template above, Claude has enough context to generate Apollo code that is correct at every layer.
The most important rule is schema-first. Write the SDL, run codegen, then implement resolvers. Claude's natural instinct is to write the resolver and infer the schema from it. That approach produces a schema that reflects implementation details rather than API design decisions, accumulates implicit assumptions, and becomes harder to evolve without breaking clients. SDL-first keeps the graph intentional.
For the mechanics of how CLAUDE.md is read at session start and how to version it alongside the schema, see CLAUDE.md explained. Claudify includes an Apollo GraphQL CLAUDE.md template, pre-configured for the schema-first SDL rules, DataLoader patterns, typePolicies configuration, graphql-codegen setup, and federation conventions shown in this guide.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify