Pothos is a code-first GraphQL schema builder for TypeScript — new SchemaBuilder<{ Context: AppContext; Scalars: CustomScalars }>({ plugins: [...] }) creates the builder. builder.objectType("User", { fields: (t) => ({ id: t.exposeID("id"), name: t.exposeString("name") }) }) defines a type. builder.queryField("users", (t) => t.field({ type: ["User"], resolve: async (_root, _args, ctx) => ctx.db.users.findMany() })) adds a query. Input types: builder.inputType("CreateUserInput", { fields: (t) => ({ email: t.string({ required: true }), name: t.string({ required: true }) }) }). builder.mutationField("createUser", (t) => t.field({ type: "User", args: { input: t.arg({ type: CreateUserInput, required: true }) }, resolve: async (_root, { input }, ctx) => createUser(input) })). The relay plugin adds builder.node for cursor pagination with pageInfo and connections. The errors plugin types thrown errors in resolvers. Claude Code generates Pothos schemas, relay connections, input types, and GraphQL server setup.
CLAUDE.md for Pothos
## Pothos Stack
- Version: @pothos/core >= 3.41, @pothos/plugin-relay >= 3.45, @pothos/plugin-errors >= 0.11
- Builder: const builder = new SchemaBuilder<{ Context: AppContext }>({ plugins: [RelayPlugin, ErrorsPlugin] })
- Object: builder.objectType("Post", { fields: (t) => ({ id: t.exposeID("id"), title: t.exposeString("title") }) })
- Query: builder.queryField("post", (t) => t.field({ type: Post, nullable: true, args: { id: t.arg.id({ required: true }) }, resolve: (_, { id }, ctx) => ctx.db.getPost(id) }))
- Mutation: builder.mutationField("createPost", (t) => t.field({ type: Post, args: { input: t.arg({ type: CreatePostInput, required: true }) }, resolve: (_, { input }, ctx) => ctx.db.createPost(input) }))
- Relay node: builder.node("User", { id: { resolve: (user) => user.id }, loadOne: (id, ctx) => ctx.db.getUser(id) })
Schema Builder
// lib/graphql/builder.ts — Pothos schema builder with plugins
import SchemaBuilder from "@pothos/core"
import RelayPlugin from "@pothos/plugin-relay"
import ErrorsPlugin from "@pothos/plugin-errors"
import ValidationPlugin from "@pothos/plugin-validation"
import ScopeAuthPlugin from "@pothos/plugin-scope-auth"
import { db } from "@/lib/db"
import type { InferSelectModel } from "drizzle-orm"
import type { users, posts, tags } from "@/lib/db/schema"
// Context type
export type AppContext = {
db: typeof db
userId: string | null
isAdmin: boolean
}
// DB row types
export type UserRow = InferSelectModel<typeof users>
export type PostRow = InferSelectModel<typeof posts>
export const builder = new SchemaBuilder<{
Context: AppContext
Objects: {
User: UserRow
Post: PostRow
}
AuthScopes: {
authenticated: boolean
admin: boolean
}
DefaultEdgesNullability: false
}>({
plugins: [
ScopeAuthPlugin,
ErrorsPlugin,
ValidationPlugin,
RelayPlugin,
],
relay: {
clientMutationId: "omit",
cursorType: "String",
defaultPageSize: 10,
maxPageSize: 100,
},
authScopes: async (context) => ({
authenticated: context.userId !== null,
admin: context.isAdmin,
}),
scopeAuthOptions: {
unauthorizedError: (_, _context, _directive, result) =>
new Error(result.message ?? "Unauthorized"),
},
errorOptions: {
defaultTypes: [Error],
},
})
// Top-level error type for mutations
builder.objectType(Error, {
name: "BaseError",
fields: (t) => ({
message: t.exposeString("message"),
}),
})
Type Definitions
// lib/graphql/types.ts — Pothos type definitions
import { builder, type UserRow, type PostRow } from "./builder"
import { db } from "@/lib/db"
import { users, posts } from "@/lib/db/schema"
import { eq, desc } from "drizzle-orm"
import { z } from "zod"
// ── User type ──────────────────────────────────────────────────────────────
const User = builder.node("User", {
id: { resolve: (user) => user.id },
loadOne: async (id, ctx) => {
return ctx.db.query.users.findFirst({ where: eq(users.id, id) }) ?? null
},
fields: (t) => ({
email: t.exposeString("email"),
name: t.exposeString("name"),
role: t.exposeString("role"),
avatarUrl: t.exposeString("avatarUrl", { nullable: true }),
createdAt: t.field({
type: "String",
resolve: (user) => user.createdAt.toISOString(),
}),
posts: t.field({
type: [Post],
resolve: async (user, _args, ctx) => {
return ctx.db.query.posts.findMany({
where: eq(posts.authorId, user.id),
orderBy: desc(posts.createdAt),
})
},
}),
}),
})
// ── Post type ──────────────────────────────────────────────────────────────
const Post = builder.node("Post", {
id: { resolve: (post) => post.id },
loadOne: async (id, ctx) => {
return ctx.db.query.posts.findFirst({ where: eq(posts.id, id) }) ?? null
},
fields: (t) => ({
title: t.exposeString("title"),
slug: t.exposeString("slug"),
content: t.exposeString("content"),
excerpt: t.exposeString("excerpt", { nullable: true }),
published: t.exposeBoolean("published"),
readingTime: t.int({
resolve: (post) => Math.ceil(post.content.split(/\s+/).length / 200),
}),
publishedAt: t.field({
type: "String",
nullable: true,
resolve: (post) => post.publishedAt?.toISOString() ?? null,
}),
createdAt: t.field({
type: "String",
resolve: (post) => post.createdAt.toISOString(),
}),
author: t.field({
type: User,
resolve: async (post, _args, ctx) => {
return ctx.db.query.users.findFirst({ where: eq(users.id, post.authorId) })
},
}),
}),
})
// ── Input types ────────────────────────────────────────────────────────────
const CreatePostInput = builder.inputType("CreatePostInput", {
fields: (t) => ({
title: t.string({ required: true, validate: { minLength: 5, maxLength: 200 } }),
content: t.string({ required: true, validate: { minLength: 20 } }),
excerpt: t.string({ validate: { maxLength: 500 } }),
published: t.boolean({ defaultValue: false }),
tags: t.stringList({ defaultValue: [] }),
}),
})
const UpdatePostInput = builder.inputType("UpdatePostInput", {
fields: (t) => ({
title: t.string({ validate: { minLength: 5, maxLength: 200 } }),
content: t.string({ validate: { minLength: 20 } }),
excerpt: t.string({ validate: { maxLength: 500 } }),
published: t.boolean(),
}),
})
// ── Queries ────────────────────────────────────────────────────────────────
builder.queryFields((t) => ({
post: t.field({
type: Post,
nullable: true,
args: {
id: t.arg.id({ required: true }),
},
resolve: async (_root, { id }, ctx) => {
return ctx.db.query.posts.findFirst({ where: eq(posts.id, String(id)) }) ?? null
},
}),
postBySlug: t.field({
type: Post,
nullable: true,
args: {
slug: t.arg.string({ required: true }),
},
resolve: async (_root, { slug }, ctx) => {
return ctx.db.query.posts.findFirst({ where: eq(posts.slug, slug) }) ?? null
},
}),
// Relay connection for paginated posts
posts: t.connection({
type: Post,
args: {
search: t.arg.string(),
published: t.arg.boolean(),
},
resolve: async (_root, args, ctx) => {
// First/after/last/before handled by relay plugin
const allPosts = await ctx.db.query.posts.findMany({
orderBy: desc(posts.createdAt),
where: args.published !== null ? eq(posts.published, args.published ?? true) : undefined,
})
return allPosts
},
}),
me: t.field({
type: User,
nullable: true,
authScopes: { authenticated: true },
resolve: async (_root, _args, ctx) => {
if (!ctx.userId) return null
return ctx.db.query.users.findFirst({ where: eq(users.id, ctx.userId) }) ?? null
},
}),
}))
// ── Mutations ──────────────────────────────────────────────────────────────
builder.mutationFields((t) => ({
createPost: t.field({
type: Post,
authScopes: { authenticated: true },
errors: { types: [Error] },
args: {
input: t.arg({ type: CreatePostInput, required: true }),
},
resolve: async (_root, { input }, ctx) => {
const slug = input.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
const [post] = await ctx.db
.insert(posts)
.values({ ...input, slug, authorId: ctx.userId! })
.returning()
return post!
},
}),
updatePost: t.field({
type: Post,
authScopes: { authenticated: true },
errors: { types: [Error] },
args: {
id: t.arg.id({ required: true }),
input: t.arg({ type: UpdatePostInput, required: true }),
},
resolve: async (_root, { id, input }, ctx) => {
const existing = await ctx.db.query.posts.findFirst({
where: eq(posts.id, String(id)),
})
if (!existing) throw new Error("Post not found")
if (existing.authorId !== ctx.userId) throw new Error("Forbidden")
const [updated] = await ctx.db
.update(posts)
.set(input)
.where(eq(posts.id, String(id)))
.returning()
return updated!
},
}),
}))
export { User, Post }
GraphQL Server
// app/api/graphql/route.ts — Yoga GraphQL server with Pothos schema
import { createYoga } from "graphql-yoga"
import { builder } from "@/lib/graphql/builder"
import "@/lib/graphql/types" // Register all type definitions
import { db } from "@/lib/db"
import { auth } from "@clerk/nextjs/server"
import type { AppContext } from "@/lib/graphql/builder"
const schema = builder.toSchema()
const yoga = createYoga<AppContext>({
schema,
graphqlEndpoint: "/api/graphql",
context: async (req): Promise<AppContext> => {
const { userId } = await auth()
return {
db,
userId: userId ?? null,
isAdmin: false, // Check from DB if needed
}
},
})
export { yoga as GET, yoga as POST }
For the TypeGraphQL alternative when class-based decorators (@ObjectType, @Field, @Resolver, @Query, @Mutation) are preferred over a builder pattern — TypeGraphQL uses experimental decorators which are more familiar to NestJS or Java Spring developers while Pothos uses a functional builder API with better type inference, see the TypeGraphQL guide. For the GraphQL Yoga + Nexus alternative when a schema-first approach with nexus.objectType and nexus.makeSchema is preferred — Nexus has similar goals to Pothos but Pothos has better performance and more active maintenance as of 2025, see the Nexus guide. The Claude Skills 360 bundle includes Pothos skill sets covering types, relay pagination, and mutations. Start with the free tier to try GraphQL schema generation.