Claude Code with Pothos: Type-Safe GraphQL Schemas Without the Boilerplate
Why Pothos without CLAUDE.md ships schemas that compile but lie
Pothos is the code-first GraphQL schema builder for TypeScript that earned its place by giving back the type safety that schema-first tools threw away. You write resolvers in TypeScript, Pothos derives the schema, and your args.userId is correctly typed as a string the moment you declare it. The price for that productivity is the configuration surface: Pothos is plugin-driven, and the order of plugins, the generics on the builder, and the integration with Prisma all matter. Get them wrong and the schema compiles but lies. The runtime types do not match the declared schema, errors crash the gateway instead of surfacing as typed errors, and auth rules silently pass on fields that should be locked.
Claude Code does not know any of this by default. It generates new SchemaBuilder({}) with no generics, no plugins, and no error type, ships a query field with nullable: false that throws on a nullable database column, and stops. The most common Claude defaults that break Pothos in production: schema builder without Context or PrismaTypes generics so resolvers see unknown instead of the actual context shape, plugin order that puts scope-auth before errors so unauthorized errors are not caught by the errors plugin, Prisma plugin without prismaTypes generation so model fields lose type safety, nullable: false on every field because the example shows it, errors plugin without a named union type so every error stringifies to "Internal server error", scope-auth rules at the type level when they should be at the field level, and N+1 queries in every list field because the dataloader plugin was not configured.
This guide covers the CLAUDE.md configuration that locks Claude Code into Pothos's type-safe, plugin-ordered model: builder declared with Context, PrismaTypes, and Scalars generics, plugins added in the correct order (errors, scope-auth, prisma, relay, dataloader), Prisma plugin with prismaTypes generated by the codegen, errors plugin with a named union type for expected errors, scope-auth rules at the field level not the type level, dataloader plugin for any list field that resolves via Prisma, and nullable fields that match the database schema. For the Prisma schema patterns Pothos depends on, Claude Code with Prisma covers the model design that pairs cleanly with the Pothos plugin.
The Pothos CLAUDE.md template
The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Pothos integration it needs to declare: the Pothos version line, the plugin list with explicit order, the builder generics, the schema layout, the auth model, the error model, the codegen output, and the hard rules that block the mistakes Claude makes most often.
# Pothos rules
## Stack
- @pothos/core ^4.x
- @pothos/plugin-errors ^4.x (FIRST in plugin order)
- @pothos/plugin-scope-auth ^4.x (after errors)
- @pothos/plugin-prisma ^4.x (after scope-auth)
- @pothos/plugin-relay ^4.x (after prisma)
- @pothos/plugin-dataloader ^4.x (last)
- prisma + @prisma/client
- graphql-yoga or @apollo/server as the HTTP server
- Codegen: pothos-prisma-types in prisma generate, schema export via writeSchema
## Project structure
- src/graphql/ , builder + plugins + schema export
- src/graphql/builder.ts , SchemaBuilder with full generics
- src/graphql/types/ , one file per GraphQL type (User, Post, ...)
- src/graphql/queries/ , one file per Query field
- src/graphql/mutations/ , one file per Mutation field
- src/graphql/scopes.ts , scope-auth rule definitions
- src/graphql/errors.ts , named error union
- prisma/schema.prisma , Prisma schema with pothos generator
- src/generated/pothos.ts , generated PrismaTypes (do NOT edit)
## Builder generics (MANDATORY)
- new SchemaBuilder<{
Context: Context
PrismaTypes: PrismaTypes
AuthScopes: { authenticated: boolean; role: 'admin' | 'editor' | 'viewer' }
Scalars: { ID: { Input: string; Output: string } }
}>({...})
- NEVER use the builder with empty generics
- Context type comes from src/graphql/context.ts
## Plugin order (MANDATORY)
- errors FIRST (wraps every field, must run before auth so it catches auth errors)
- scope-auth SECOND (gates field access)
- prisma THIRD (provides t.prismaField helpers that depend on auth)
- relay FOURTH (provides connection helpers)
- dataloader LAST (wraps any field that batches)
## Nullable discipline (MANDATORY)
- nullable MUST match the Prisma column nullability
- prismaField inherits nullability from the model, do NOT override
- Custom resolvers MUST set nullable: true if the resolver can return null or throw
- NEVER set nullable: false on a field whose resolver returns Promise<T | null>
## Auth (MANDATORY at the field level)
- authScopes on every Query and Mutation field (NEVER on types only)
- $granted scope for scopes the caller earned via prior fields
- NEVER trust args for auth, always pull from context
- NEVER define a public field without authScopes: {} (explicit empty)
## Errors (MANDATORY for any mutation that can fail)
- types: [BaseError, ValidationError, AuthError] on errors plugin config
- errors: { types: [...] } on the field for expected error union
- NEVER throw a plain Error in a resolver, always one of the registered types
- Client-facing messages live on the typed error, not as 'message' strings
## Hard rules
- NEVER instantiate SchemaBuilder without full generics
- NEVER change plugin order without re-running codegen
- NEVER skip the errors plugin on a mutation that can fail (returns Internal error)
- NEVER put scope-auth before errors (auth errors will crash the gateway)
- NEVER use t.field for a Prisma-backed type, use t.prismaField for the dataloader
- NEVER set nullable: false to satisfy TypeScript, fix the resolver instead
- ALWAYS re-run prisma generate after editing prisma/schema.prisma
- ALWAYS export the schema to a .graphql file for client codegen
Five rules here prevent the majority of incidents Claude generates without them.
The builder generics rule prevents the silent loss of type safety. The SchemaBuilder<{}> instance compiles, but every t.field resolver sees unknown for args and parent, and the IDE autocomplete dies. The rule "MANDATORY full generics" forces Claude to declare the context, the Prisma types, the auth scopes, and the scalar overrides up front, so every field downstream has the types Pothos can derive.
The plugin order rule prevents the most subtle class of Pothos bug: errors that the errors plugin should have caught crashing the gateway because scope-auth ran first. Pothos wraps fields by composing plugins as middleware. Errors-then-scope-auth wraps the auth check inside the error handler, so an unauthorized error becomes a typed AuthError in the response. Scope-auth-then-errors does the opposite, so the auth throw escapes to the gateway and the client sees INTERNAL_SERVER_ERROR. The rule "errors FIRST, scope-auth SECOND" makes the middleware composition explicit.
The nullable discipline rule prevents the runtime crash that happens when Pothos declares a field non-null and the resolver returns null. GraphQL's contract says non-null fields propagate to the parent if they are null, so a single nullable column declared as non-null can null out an entire query response. Claude defaults to nullable: false because the docs example shows it. The rule "nullable MUST match the Prisma column" forces alignment between the database and the schema.
The auth at field level rule prevents the common mistake of locking down a type without locking down its access points. A User type with authScopes: { authenticated: true } at the type level looks safe, but User fields nested under a different query that has weaker auth bypass the type-level check. The rule "authScopes on every Query and Mutation field" makes the access surface explicit at the entry point.
The errors plugin rule prevents the support-debt of "the API returned Internal server error but the database log shows a validation failure." With the errors plugin and a typed error union, the client receives { __typename: 'ValidationError', field: 'email', message: 'Invalid format' } and can branch on it. Without it, the user sees a generic message and the engineer reads logs. The rule "types: [BaseError, ValidationError, AuthError]" makes the error vocabulary part of the schema.
Install and builder setup
Install the core and plugins:
pnpm add @pothos/core @pothos/plugin-errors @pothos/plugin-scope-auth @pothos/plugin-prisma @pothos/plugin-relay @pothos/plugin-dataloader
pnpm add prisma @prisma/client graphql-yoga graphql
Configure the Prisma generator to emit Pothos types in prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
generator pothos {
provider = "prisma-pothos-types"
output = "../src/generated/pothos.ts"
clientOutput = "@prisma/client"
}
Run codegen:
pnpm prisma generate
Build the builder with explicit generics:
// src/graphql/builder.ts
import SchemaBuilder from '@pothos/core';
import ErrorsPlugin from '@pothos/plugin-errors';
import ScopeAuthPlugin from '@pothos/plugin-scope-auth';
import PrismaPlugin from '@pothos/plugin-prisma';
import RelayPlugin from '@pothos/plugin-relay';
import DataloaderPlugin from '@pothos/plugin-dataloader';
import { prisma } from '@/db/prisma';
import type PrismaTypes from '@/generated/pothos';
import type { Context } from './context';
import { BaseError } from './errors';
export const builder = new SchemaBuilder<{
Context: Context;
PrismaTypes: PrismaTypes;
AuthScopes: {
authenticated: boolean;
role: 'admin' | 'editor' | 'viewer';
organisation: string;
};
Scalars: {
ID: { Input: string; Output: string };
DateTime: { Input: Date; Output: Date };
};
}>({
plugins: [
ErrorsPlugin,
ScopeAuthPlugin,
PrismaPlugin,
RelayPlugin,
DataloaderPlugin,
],
errors: {
defaultTypes: [BaseError],
},
scopeAuth: {
authScopes: (ctx) => ({
authenticated: ctx.user !== null,
role: (target) => ctx.user?.role === target,
organisation: (id) => ctx.user?.organisationId === id,
}),
},
prisma: {
client: prisma,
exposeDescriptions: true,
filterConnectionTotalCount: true,
},
relay: {
clientMutationId: 'omit',
cursorType: 'String',
},
});
builder.queryType({});
builder.mutationType({});
Three things in this snippet that the CLAUDE.md rule enforces. The generics declare Context, PrismaTypes, AuthScopes, and Scalars so every field downstream is fully typed. The plugins array has errors first, scope-auth second, prisma third, relay fourth, dataloader last. The plugin configurations are colocated with the builder, so the codegen and runtime stay in sync.
Defining types with Prisma plugin
The Prisma plugin gives you builder.prismaObject which derives a GraphQL type from a Prisma model. The type is bound to the model's columns, the dataloader plugin batches the N+1 queries automatically, and the auth scopes apply per field.
// src/graphql/types/user.ts
import { builder } from '../builder';
builder.prismaObject('User', {
fields: (t) => ({
id: t.exposeID('id'),
email: t.exposeString('email', {
authScopes: { authenticated: true },
}),
name: t.exposeString('name'),
role: t.exposeString('role'),
createdAt: t.expose('createdAt', { type: 'DateTime' }),
posts: t.relation('posts', {
query: (_args, ctx) => ({
where: { organisationId: ctx.user?.organisationId },
orderBy: { createdAt: 'desc' },
}),
}),
}),
});
Four patterns in this type that the CLAUDE.md rule enforces. The prismaObject is bound to the User model in PrismaTypes, so the t.exposeX calls only accept fields that exist on the model. The authScopes: { authenticated: true } is at the field level, so anonymous queries can read id and name but not email. The t.relation('posts', ...) uses the Prisma plugin's relation helper which adds dataloader batching automatically. The where clause in the relation query pulls the organisation from context, so a user cannot read another organisation's posts even by chaining through a public field.
For the Prisma schema patterns the model design depends on, Claude Code with Prisma covers the schema rules that pair cleanly with the Pothos plugin.
Typed errors with the errors plugin
The errors plugin wraps every field with an error union. Expected errors become typed nodes in the response. Unexpected errors propagate as gateway errors.
// src/graphql/errors.ts
import { builder } from './builder';
export const BaseError = builder.objectRef<Error>('Error').implement({
fields: (t) => ({
message: t.exposeString('message'),
}),
});
export class ValidationError extends Error {
field: string;
constructor(field: string, message: string) {
super(message);
this.field = field;
this.name = 'ValidationError';
}
}
export class AuthError extends Error {
constructor(message: string = 'Not authorised') {
super(message);
this.name = 'AuthError';
}
}
builder.objectType(ValidationError, {
name: 'ValidationError',
interfaces: [BaseError],
fields: (t) => ({
field: t.exposeString('field'),
}),
});
builder.objectType(AuthError, {
name: 'AuthError',
interfaces: [BaseError],
});
A mutation that uses the typed errors:
// src/graphql/mutations/createPost.ts
import { builder } from '../builder';
import { ValidationError, AuthError } from '../errors';
import { prisma } from '@/db/prisma';
builder.mutationField('createPost', (t) =>
t.prismaField({
type: 'Post',
args: {
title: t.arg.string({ required: true }),
content: t.arg.string({ required: true }),
},
errors: {
types: [ValidationError, AuthError],
},
authScopes: { authenticated: true },
async resolve(_query, _parent, args, ctx) {
if (!ctx.user) throw new AuthError();
if (args.title.length < 3) {
throw new ValidationError('title', 'Title must be at least 3 characters');
}
return prisma.post.create({
data: {
title: args.title,
content: args.content,
authorId: ctx.user.id,
organisationId: ctx.user.organisationId,
},
});
},
}),
);
The client now gets a union type for the result. In a TypeScript client codegen, the response is:
type CreatePostResult =
| { __typename: 'Post'; id: string; title: string; content: string }
| { __typename: 'ValidationError'; message: string; field: string }
| { __typename: 'AuthError'; message: string };
The client branches on __typename to handle each case explicitly.
Add an error pattern rule to CLAUDE.md:
## Typed error pattern (CONCRETE)
For each mutation that can fail with expected errors:
errors: { types: [<ErrorType1>, <ErrorType2>] }
For each expected error type:
- extend Error with named class
- builder.objectType(ErrorClass, { name, interfaces: [BaseError], fields })
In the resolver:
- throw new ValidationError('field', 'message') for validation failures
- throw new AuthError() for auth failures
- NEVER throw new Error('message'), always a typed error
The client receives a union and branches on __typename.
Common Claude Code mistakes with Pothos
Six patterns Claude generates incorrectly without CLAUDE.md constraints.
1. Builder without generics
Claude generates const builder = new SchemaBuilder({}). Every resolver downstream sees args: unknown and the IDE drops type hints.
Correct pattern: declare Context, PrismaTypes, AuthScopes, and Scalars generics. The downstream type safety is the entire point of Pothos.
2. scope-auth before errors
Claude generates plugins: [ScopeAuthPlugin, ErrorsPlugin, ...]. Auth errors thrown by scope-auth escape to the gateway because the errors plugin wrapper is inside the auth wrapper, not outside.
Correct pattern: plugins: [ErrorsPlugin, ScopeAuthPlugin, ...]. Errors wraps auth, auth errors become typed nodes.
3. nullable: false on nullable database columns
Claude generates t.exposeString('email', { nullable: false }) on a User model where email is String? in Prisma. The first user with a null email crashes the query with a non-null propagation error.
Correct pattern: omit nullable and let the Prisma plugin derive it from the model, or set nullable: true if the column is nullable.
4. authScopes at the type level only
Claude generates builder.prismaObject('User', { authScopes: { authenticated: true }, fields: (t) => ({...}) }). The auth check runs when the type is the root, not when it is nested. A Post.author field returns the user without re-checking.
Correct pattern: authScopes at the field level on every Query and Mutation entry point. Type-level authScopes are an additional safety net, not the primary check.
5. t.field instead of t.prismaField for Prisma-backed types
Claude generates:
t.field({
type: 'Post',
resolve: (_parent, _args, ctx) =>
prisma.post.findMany({ where: { organisationId: ctx.user.organisationId } }),
})
This works but bypasses the dataloader plugin, so nested fields run their own queries. With 50 posts each loading their author, the page makes 50 author queries.
Correct pattern: t.prismaField({ type: 'Post', resolve: (query, ...) => prisma.post.findMany({ ...query, where: {...} }) }). The query argument carries the dataloader-optimised selection.
6. Throwing plain errors in mutations
Claude generates throw new Error('Title required') in a mutation resolver. The client sees INTERNAL_SERVER_ERROR and no field information.
Correct pattern: throw new ValidationError('title', 'Title is required') with the errors plugin configured to surface the type.
Add this list to CLAUDE.md with the corrected form for each.
Permission hooks for Pothos projects
A Pothos project has CLI surface for codegen, schema export, and testing. Permission hooks gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(pnpm prisma generate*)",
"Bash(pnpm prisma migrate dev*)",
"Bash(pnpm test*)",
"Bash(pnpm tsx scripts/export-schema.ts*)"
],
"deny": [
"Bash(pnpm prisma migrate reset*)",
"Bash(pnpm prisma db push --force-reset*)",
"Bash(rm -rf src/generated*)"
]
}
}
Codegen and schema export are safe. Resetting the database or wiping the generated types needs explicit confirmation.
Building Pothos schemas that match the contract they advertise
The Pothos CLAUDE.md in this guide produces schemas where the builder has every generic Pothos uses, the plugins are in the order that makes errors plugin wrap auth, every type is bound to a Prisma model via the prisma plugin so dataloader batches the N+1s, every Query and Mutation has authScopes at the field level so the access surface is explicit, every mutation that can fail has a typed error union, and nullability matches the database schema instead of being chosen to satisfy TypeScript.
The underlying principle is the same as any code-first framework with Claude Code. Pothos without a CLAUDE.md produces schemas that compile and lie: types that say non-null and resolvers that return null, mutations that throw plain errors and surface as Internal server error, auth at the type level that gets bypassed by nested access, and N+1 queries because the dataloader plugin was not in the chain.
For the Prisma schema patterns the type generation depends on, Claude Code with Prisma covers the model design that pairs cleanly with the Pothos plugin. For the Apollo client setup that consumes the schema, Claude Code with Apollo GraphQL covers the client codegen patterns that work well with Pothos's typed error unions. Claudify includes a Pothos-specific CLAUDE.md template with the builder generics, the plugin order, the Prisma integration, the typed error pattern, the field-level auth model, and all six common-mistake rules pre-configured.
Get Claudify. Ship Pothos schemas that respect their own type contract.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify