Every time I added a new model to my Prisma schema, writing the CRUD API by hand was a grind. Once I started handing the schema to Claude Code, it would generate Route Handlers, Zod validators, and the rest of the stack in one shot.
This article walks through a full workflow for generating Zod validators, Next.js App Router Route Handlers, and tRPC routers from a Prisma schema — with specific prompts at each step, and with type safety throughout.
What Does the Full Schema-Driven API Generation Workflow Look Like?
Before diving in, here's the end-to-end flow:
prisma/schema.prisma
↓
Claude Code reads and understands it
↓
┌──────────────────────────────┐
│ Zod validators │ lib/validations/
│ Route Handlers │ app/api/**
│ tRPC routers │ server/routers/
│ Auth middleware │ middleware/
│ Tests │ __tests__/
└──────────────────────────────┘
The key principle is treating the Prisma schema as the single source of truth. When the schema changes, you give Claude Code the diff and it updates everything downstream. No more hunting for every place a field is referenced.
Tech stack assumed throughout:
- Next.js 15 (App Router)
- Prisma 6.x + PostgreSQL
- TypeScript 5.x
- Zod 3.x
- tRPC 11.x (skip that section if you're using REST only)
How Do You Set Up the Prisma MCP Server?
Prisma's official MCP server lets Claude Code read your schema, migration history, and database state directly — rather than relying on you to paste the schema into every prompt.
npm install -D @prisma/mcp-server
Add it to ~/.claude.json:
{
"mcpServers": {
"prisma": {
"command": "npx",
"args": [
"-y",
"@prisma/mcp-server",
"--schema", "./prisma/schema.prisma"
]
}
}
}
If the MCP server isn't available in your environment, pointing Claude Code at the schema file directly works fine:
claude "Read prisma/schema.prisma and confirm you understand the model structure"
Here's the sample schema we'll use throughout:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id String @id @default(cuid())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum Role {
USER
ADMIN
}
How Do You Generate Zod Validators from the Prisma Schema?
Start with validators. Everything else will import from here.
Read prisma/schema.prisma and generate Zod validators for the following:
- User model: create, update, and query schemas
- Post model: create, update, and query schemas
Requirements:
- Output to lib/validations/ directory, one file per model (user.ts, post.ts)
- Keep types consistent with the Prisma-generated types
- Use z.string().email() for email fields
- Derive update schemas from create schemas using .partial()
- Export TypeScript types inferred from each schema
The generated output should look something like this:
// lib/validations/user.ts
import { z } from 'zod'
import { Role } from '@prisma/client'
export const createUserSchema = z.object({
email: z.string().email('Please enter a valid email address'),
name: z.string().min(1).max(100).optional(),
role: z.nativeEnum(Role).default('USER'),
})
export const updateUserSchema = createUserSchema.partial()
export const userQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
role: z.nativeEnum(Role).optional(),
search: z.string().optional(),
})
export type CreateUserInput = z.infer<typeof createUserSchema>
export type UpdateUserInput = z.infer<typeof updateUserSchema>
export type UserQuery = z.infer<typeof userQuerySchema>
After generation, verify type correctness:
npx tsc --noEmit
If there are errors, paste them directly back to Claude Code and it will fix them.
How Do You Auto-Generate Next.js App Router Route Handlers?
With validators in place, generate the Route Handlers:
Using the validators in lib/validations/user.ts and lib/validations/post.ts,
generate Route Handlers for the following endpoints:
User API:
- GET /api/users — list with pagination and search
- POST /api/users — create
- GET /api/users/[id] — get single
- PATCH /api/users/[id] — update
- DELETE /api/users/[id] — delete
Post API:
- Same CRUD pattern
Requirements:
- Import Prisma Client from lib/prisma.ts
- Return appropriate HTTP status codes (400 for validation, 404 for not found)
- Use NextResponse for all responses
- Follow App Router file/directory conventions
Here's what the generated list endpoint looks like:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { createUserSchema, userQuerySchema } from '@/lib/validations/user'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const query = userQuerySchema.safeParse(Object.fromEntries(searchParams))
if (!query.success) {
return NextResponse.json(
{ error: 'Invalid query parameters', details: query.error.flatten() },
{ status: 400 }
)
}
const { page, limit, role, search } = query.data
const skip = (page - 1) * limit
const where = {
...(role && { role }),
...(search && {
OR: [
{ email: { contains: search, mode: 'insensitive' as const } },
{ name: { contains: search, mode: 'insensitive' as const } },
],
}),
}
const [users, total] = await Promise.all([
prisma.user.findMany({ where, skip, take: limit, orderBy: { createdAt: 'desc' } }),
prisma.user.count({ where }),
])
return NextResponse.json({
data: users,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
})
}
export async function POST(request: NextRequest) {
const body = await request.json()
const parsed = createUserSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: 'Validation failed', details: parsed.error.flatten() },
{ status: 400 }
)
}
const user = await prisma.user.create({ data: parsed.data })
return NextResponse.json({ data: user }, { status: 201 })
}
Pagination, validation, structured error responses — all generated. What would normally take an hour per model is done in minutes.
How Do You Generate tRPC Routers from the Prisma Schema?
For tRPC projects, the same approach generates routers instead of Route Handlers:
Using the Zod schemas in lib/validations/, generate tRPC routers:
- server/routers/user.ts — User CRUD procedures
- server/routers/post.ts — Post CRUD procedures
- server/routers/index.ts — combine into appRouter
Requirements:
- Use tRPC v11 syntax
- publicProcedure for read operations, protectedProcedure for writes
- Reuse the existing Zod schemas for input validation
- Use TRPCError with appropriate codes for errors
The generated router:
// server/routers/user.ts
import { z } from 'zod'
import { TRPCError } from '@trpc/server'
import { createTRPCRouter, publicProcedure, protectedProcedure } from '@/server/trpc'
import { prisma } from '@/lib/prisma'
import { createUserSchema, updateUserSchema, userQuerySchema } from '@/lib/validations/user'
export const userRouter = createTRPCRouter({
list: publicProcedure
.input(userQuerySchema)
.query(async ({ input }) => {
const { page, limit, role, search } = input
const skip = (page - 1) * limit
const where = {
...(role && { role }),
...(search && {
OR: [
{ email: { contains: search, mode: 'insensitive' as const } },
{ name: { contains: search, mode: 'insensitive' as const } },
],
}),
}
const [users, total] = await Promise.all([
prisma.user.findMany({ where, skip, take: limit }),
prisma.user.count({ where }),
])
return { users, total, totalPages: Math.ceil(total / limit) }
}),
create: protectedProcedure
.input(createUserSchema)
.mutation(async ({ input }) => {
const existing = await prisma.user.findUnique({ where: { email: input.email } })
if (existing) {
throw new TRPCError({ code: 'CONFLICT', message: 'Email already in use' })
}
return prisma.user.create({ data: input })
}),
update: protectedProcedure
.input(z.object({ id: z.string(), data: updateUserSchema }))
.mutation(async ({ input }) => {
const user = await prisma.user.findUnique({ where: { id: input.id } })
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
return prisma.user.update({ where: { id: input.id }, data: input.data })
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await prisma.user.delete({ where: { id: input.id } })
return { success: true }
}),
})
How Do You Add Auth Middleware with Claude Code?
With the API structure in place, add authentication guards:
Add authentication and authorization middleware to the generated Route Handlers.
Requirements:
- Use NextAuth.js v5 session checking
- Require ADMIN role for: DELETE /api/users/[id]
- Require authentication for: all POST, PATCH, DELETE endpoints
- Return 401 for unauthenticated requests, 403 for unauthorized
- Create reusable withAuth and withAdmin wrapper functions
// lib/auth-middleware.ts
import { auth } from '@/auth'
import { NextRequest, NextResponse } from 'next/server'
type Handler = (req: NextRequest, context: { params: Promise<Record<string, string>> }) => Promise<NextResponse>
export function withAuth(handler: Handler): Handler {
return async (req, context) => {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
return handler(req, context)
}
}
export function withAdmin(handler: Handler): Handler {
return async (req, context) => {
const session = await auth()
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (session.user.role !== 'ADMIN') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return handler(req, context)
}
}
Applying withAdmin(handler) to the DELETE Route Handler is all it takes.
Can You Automate Test Generation for the Generated API Code?
Tests get generated the same way:
Write Vitest tests for the Route Handlers in app/api/users/route.ts.
Requirements:
- Mock Prisma Client using vitest-mock-extended
- Cover: list, create, update, delete (happy path)
- Cover: validation errors, not found, auth errors
- Place tests in __tests__/api/users.test.ts
// __tests__/api/users.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
import { GET, POST } from '@/app/api/users/route'
import { prisma } from '@/lib/prisma'
vi.mock('@/lib/prisma', () => ({
prisma: {
user: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}))
describe('GET /api/users', () => {
it('returns a paginated user list', async () => {
vi.mocked(prisma.user.findMany).mockResolvedValue([
{ id: '1', email: 'test@example.com', name: 'Test', role: 'USER', createdAt: new Date(), updatedAt: new Date() },
])
vi.mocked(prisma.user.count).mockResolvedValue(1)
const request = new NextRequest('http://localhost/api/users')
const response = await GET(request)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.data).toHaveLength(1)
expect(data.pagination.total).toBe(1)
})
})
How Do You Update the API Layer When the Prisma Schema Changes?
When the schema evolves, standardize the update prompt so nothing falls through the cracks:
Apply the following schema changes to the API layer:
Changes:
- Added tags: String[] to Post model
- Added avatar: String? to User model
Files to update:
- lib/validations/user.ts, post.ts
- app/api/users/route.ts, app/api/posts/route.ts
- Corresponding test files
Constraint: don't change existing API behavior — new fields are optional.
Using a consistent prompt structure like this eliminates the "I updated the schema but forgot to update the API" bug.
Here's a checklist to run after every schema change:
prisma migrate dev— apply migration to database- Give Claude Code the schema diff, ask it to update validators, handlers, and tests
npx tsc --noEmit— confirm no type errorsnpm test— confirm tests passprisma generate— regenerate Prisma Client
Wrapping Up
Combining Prisma's schema-first approach with Claude Code's code generation creates a workflow where most of the API boilerplate writes itself.
Key points:
- Prisma MCP server lets Claude Code reference the schema directly without repeated copy-pasting
- Generate Zod validators first — Route Handlers and tRPC routers both import from them
- Auth middleware as wrapper functions keeps Route Handlers clean and the auth logic centralized
- Standardize the schema-change prompt so updates propagate consistently across validators, endpoints, and tests
If CRUD implementation was taking an hour per model, this workflow brings it down to 10–15 minutes — with better type safety than hand-written code. Start with the validators and build outward from there.