Simplest way to setup Next.js with API and SDK?
design the simplest/cleanest way to get started with next.js. a route, a contract, an openapi generation, an SDK that i could use from other projects
Now I have a complete picture. Let me design the simplest and cleanest setup for you:
#Simplest oRPC + Next.js Setup
Here's a clean, minimal setup with a route, contract-first approach, OpenAPI generation, and SDK for external use.
##š Project Structure
src/
āāā contract/ # Shared contract (can be published as npm package)
ā āāā index.ts
ā āāā todos.ts
āāā schemas/
ā āāā todo.ts
āāā server/
ā āāā orpc.ts # Server builder with middlewares
ā āāā router.ts # Procedure implementations
āāā lib/
ā āāā client.ts # oRPC client for frontend
āāā app/
ā āāā rpc/[[...rest]]/route.ts # RPC handler (binary, fastest)
ā āāā api/[[...rest]]/route.ts # OpenAPI handler (REST)
āāā scripts/
āāā generate-openapi.ts # Script to generate spec
##1ļøā£ Schema Definition (src/schemas/todo.ts)
import * as z from 'zod'
export const TodoSchema = z.object({
id: z.number().int(),
title: z.string(),
completed: z.boolean(),
})
export const CreateTodoSchema = z.object({
title: z.string().min(1),
})
export type Todo = z.infer<typeof TodoSchema>
##2ļøā£ Contract (src/contract/todos.ts)
This can be published as a separate npm package for SDK consumers.
import { oc } from '@orpc/contract'
import * as z from 'zod'
import { TodoSchema, CreateTodoSchema } from '../schemas/todo'
export const listTodos = oc
.route({ method: 'GET', path: '/todos', summary: 'List todos', tags: ['Todos'] })
.input(z.object({ limit: z.number().int().default(10) }))
.output(z.array(TodoSchema))
export const createTodo = oc
.route({ method: 'POST', path: '/todos', summary: 'Create todo', tags: ['Todos'] })
.input(CreateTodoSchema)
.output(TodoSchema)
export const getTodo = oc
.route({ method: 'GET', path: '/todos/{id}', summary: 'Get todo', tags: ['Todos'] })
.input(z.object({ id: z.number().int() }))
.output(TodoSchema)
src/contract/index.ts
import { listTodos, createTodo, getTodo } from './todos'
export const contract = {
todos: { list: listTodos, create: createTodo, get: getTodo },
}
##3ļøā£ Server Implementation (src/server/orpc.ts)
import { os } from '@orpc/server'
// Base builder - add middlewares as needed
export const pub = os
// Example: authenticated builder
// export const authed = pub.use(authMiddleware)
src/server/router.ts
import { implement } from '@orpc/server'
import { contract } from '../contract'
import { pub } from './orpc'
// In-memory store (replace with your DB)
let todos = [{ id: 1, title: 'Learn oRPC', completed: false }]
export const router = implement(contract, {
prefix: '/api' // Optional: defaults to /api
}).$router({
todos: {
list: pub.handler(async ({ input }) => {
return todos.slice(0, input.limit)
}),
create: pub.handler(async ({ input }) => {
const todo = { id: todos.length + 1, ...input, completed: false }
todos.push(todo)
return todo
}),
get: pub.handler(async ({ input }) => {
const todo = todos.find(t => t.id === input.id)
if (!todo) throw new Error('Not found')
return todo
}),
},
})
##4ļøā£ API Routes
RPC Handler (src/app/rpc/[[...rest]]/route.ts) - Fast binary protocol:
import { RPCHandler } from '@orpc/server/fetch'
import { router } from '@/server/router'
const handler = new RPCHandler(router)
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, { prefix: '/rpc', context: {} })
return response ?? new Response('Not found', { status: 404 })
}
export { handleRequest as GET, handleRequest as POST }
OpenAPI Handler (src/app/api/[[...rest]]/route.ts) - REST API + Swagger UI:
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { SmartCoercionPlugin } from '@orpc/json-schema'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { router } from '@/server/router'
import { TodoSchema, CreateTodoSchema } from '@/schemas/todo'
const handler = new OpenAPIHandler(router, {
plugins: [
new SmartCoercionPlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
}),
new OpenAPIReferencePlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
info: { title: 'My API', version: '1.0.0' },
commonSchemas: {
Todo: { schema: TodoSchema },
CreateTodo: { schema: CreateTodoSchema },
},
},
}),
],
})
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, { prefix: '/api', context: {} })
return response ?? new Response('Not found', { status: 404 })
}
export { handleRequest as GET, handleRequest as POST, handleRequest as PUT, handleRequest as DELETE }
š Swagger UI is automatically available at
/api/reference!
##5ļøā£ Client for Frontend (src/lib/client.ts)
import type { router } from '@/server/router'
import type { RouterClient } from '@orpc/server'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
const link = new RPCLink({
url: typeof window !== 'undefined'
? `${window.location.origin}/rpc`
: 'http://localhost:3000/rpc',
})
export const client: RouterClient<typeof router> = createORPCClient(link)
// Usage: const todos = await client.todos.list({ limit: 10 })
##6ļøā£ Generate OpenAPI Spec for External SDK (scripts/generate-openapi.ts)
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { writeFileSync } from 'fs'
import { contract } from '../src/contract'
import { TodoSchema, CreateTodoSchema } from '../src/schemas/todo'
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
})
const spec = generator.generate(contract, {
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
commonSchemas: {
Todo: { schema: TodoSchema },
CreateTodo: { schema: CreateTodoSchema },
},
})
writeFileSync('./openapi.json', JSON.stringify(spec, null, 2))
console.log('ā
OpenAPI spec generated!')
Run with: npx tsx scripts/generate-openapi.ts
##7ļøā£ Generate SDK with Hey API
# Install hey-api
pnpm add -D @hey-api/openapi-ts @orpc/hey-api
# Generate client SDK from your OpenAPI spec
npx openapi-ts -i ./openapi.json -o ./sdk
Use the generated SDK with oRPC:
import { experimental_toORPCClient } from '@orpc/hey-api'
import * as sdk from './sdk'
const client = experimental_toORPCClient(sdk)
// Fully typed!
const todos = await client.getTodos({ query: { limit: 10 } })
##š¦ Dependencies
{
"dependencies": {
"@orpc/client": "latest",
"@orpc/contract": "latest",
"@orpc/server": "latest",
"@orpc/openapi": "latest",
"@orpc/json-schema": "latest",
"@orpc/zod": "latest",
"zod": "^4.0.0"
},
"devDependencies": {
"@hey-api/openapi-ts": "^0.80.0",
"@orpc/hey-api": "latest"
}
}
##Summary
| Layer | File | Purpose |
|---|---|---|
| Schema | schemas/todo.ts | Zod schemas (shared) |
| Contract | contract/ | API definition (publishable) |
| Server | server/router.ts | Implementation |
| RPC Route | app/rpc/[[...rest]]/route.ts | Fast binary protocol |
| OpenAPI Route | app/api/[[...rest]]/route.ts | REST + Swagger UI |
| Client | lib/client.ts | Frontend usage |
| SDK Gen | scripts/generate-openapi.ts | For external consumers |
The contract and schemas can be extracted to a separate package and published to npm, allowing external teams to:
- Use the OpenAPI spec to generate SDKs in any language
- Use
@orpc/hey-apifor a typed oRPC client from the generated SDK
what's "@orpc/hey-api" ? also can we simplify the file structure? i see lots of directories and i generally don't like that a lot. eg why not contract.ts, schemas.ts... also how does error handling work?
#Simplified Setup (Single Files)
Here's a much cleaner structure:
src/
āāā contract.ts # Contract + schemas together
āāā server.ts # Router implementation
āāā client.ts # Frontend client
āāā app/
āāā api/[[...rest]]/route.ts # Single handler for both RPC + OpenAPI
##src/contract.ts ā Schema + Contract in one file
import { oc } from '@orpc/contract'
import * as z from 'zod'
// ============ SCHEMAS ============
export const TodoSchema = z.object({
id: z.number().int(),
title: z.string(),
completed: z.boolean(),
})
export const CreateTodoSchema = z.object({
title: z.string().min(1),
})
// ============ CONTRACT ============
export const contract = {
todos: {
list: oc
.route({ method: 'GET', path: '/todos', summary: 'List todos' })
.input(z.object({ limit: z.number().int().default(10) }))
.output(z.array(TodoSchema)),
create: oc
.route({ method: 'POST', path: '/todos', summary: 'Create todo' })
.input(CreateTodoSchema)
.output(TodoSchema),
get: oc
.route({ method: 'GET', path: '/todos/{id}', summary: 'Get todo' })
.input(z.object({ id: z.number().int() }))
.output(TodoSchema)
.errors({
NOT_FOUND: { message: 'Todo not found' },
}),
},
}
##src/server.ts ā Implementation
import { implement, ORPCError } from '@orpc/server'
import { contract } from './contract'
// Fake DB
let todos = [{ id: 1, title: 'Learn oRPC', completed: false }]
export const router = implement(contract).$router({
todos: {
list: ({ input }) => todos.slice(0, input.limit),
create: ({ input }) => {
const todo = { id: todos.length + 1, ...input, completed: false }
todos.push(todo)
return todo
},
get: ({ input, errors }) => {
const todo = todos.find(t => t.id === input.id)
if (!todo) {
// Type-safe error (knows NOT_FOUND is valid here)
throw errors.NOT_FOUND()
}
return todo
},
},
})
##src/app/api/[[...rest]]/route.ts ā Single Route Handler
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { router } from '@/server'
import { TodoSchema, CreateTodoSchema } from '@/contract'
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
info: { title: 'My API', version: '1.0.0' },
commonSchemas: {
Todo: { schema: TodoSchema },
CreateTodo: { schema: CreateTodoSchema },
},
},
}),
],
})
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, { prefix: '/api', context: {} })
return response ?? new Response('Not found', { status: 404 })
}
export { handleRequest as GET, handleRequest as POST, handleRequest as PUT, handleRequest as DELETE }
Visit
/api/referencefor Swagger UI,/api/spec.jsonfor the OpenAPI spec.
##src/client.ts ā Frontend Client
import type { router } from './server'
import type { RouterClient } from '@orpc/server'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
const link = new RPCLink({
url: typeof window !== 'undefined'
? `${window.location.origin}/api`
: 'http://localhost:3000/api',
})
export const client: RouterClient<typeof router> = createORPCClient(link)
##Error Handling
oRPC has two approaches:
###1. Simple ā Just throw ORPCError
import { ORPCError } from '@orpc/server'
// In any handler:
throw new ORPCError('NOT_FOUND', { message: 'Todo not found' })
throw new ORPCError('UNAUTHORIZED')
throw new ORPCError('BAD_REQUEST', { message: 'Invalid input', data: { field: 'title' } })
Standard codes: BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, INTERNAL_SERVER_ERROR, etc.
###2. Type-Safe ā Define with .errors()
const getTodo = oc
.route({ method: 'GET', path: '/todos/{id}' })
.input(z.object({ id: z.number() }))
.output(TodoSchema)
.errors({
NOT_FOUND: {
message: 'Todo not found',
data: z.object({ id: z.number() }), // optional typed error data
},
})
// In handler, `errors` is injected and typed:
handler: ({ input, errors }) => {
if (!todo) {
throw errors.NOT_FOUND({ data: { id: input.id } }) // fully typed!
}
}
###Client-side error handling:
import { ORPCError, isORPCError } from '@orpc/client'
try {
await client.todos.get({ id: 999 })
} catch (e) {
if (isORPCError(e)) {
console.log(e.code) // 'NOT_FOUND'
console.log(e.message) // 'Todo not found'
console.log(e.data) // { id: 999 }
}
}
##Generating OpenAPI for External SDKs
You can grab the spec from /api/spec.json directly, or generate it programmatically:
// scripts/generate-spec.ts
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { writeFileSync } from 'fs'
import { contract, TodoSchema, CreateTodoSchema } from '../src/contract'
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
})
const spec = generator.generate(contract, {
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
commonSchemas: {
Todo: { schema: TodoSchema },
CreateTodo: { schema: CreateTodoSchema },
},
})
writeFileSync('./openapi.json', JSON.stringify(spec, null, 2))
Then use any OpenAPI codegen tool (Hey API, openapi-generator, etc.) to create SDKs in any language.
##Summary
| File | Purpose |
|---|---|
contract.ts | Schemas + API contract (shareable) |
server.ts | Implementation |
client.ts | Frontend client |
app/api/[[...rest]]/route.ts | REST API + Swagger UI |
4 files total. Clean and simple! š
beautiful! about openapi spec generation: can that be a route? and generated dynamically? or the recommendation is for us to generate it once at build time?
The OpenAPIReferencePlugin already serves the spec dynamically as a route! Here's what you get out of the box:
##With OpenAPIReferencePlugin (already in your setup)
| Route | What it serves |
|---|---|
/api/spec.json | OpenAPI JSON spec (dynamic) |
/api/reference | Scalar UI (interactive docs) |
That's it ā no build step needed!
// Your route.ts already has this:
new OpenAPIReferencePlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
info: { title: 'My API', version: '1.0.0' },
// ...
},
// These are the defaults:
specPath: '/spec.json', // ā /api/spec.json
docsPath: '/reference', // ā /api/reference
docsProvider: 'scalar', // or 'swagger'
})
##When to use build-time generation?
Dynamic (default) ā recommended for most cases:
- Spec always matches your code
- Zero extra build steps
- Good for development and most production use
Build-time ā only if you need to:
- Publish the spec to a registry/portal
- Generate SDKs in CI/CD
- Serve a static file from a CDN
- Version control the spec file
##Quick example: Download spec for SDK generation
# Just curl your running server
curl http://localhost:3000/api/spec.json > openapi.json
# Then generate SDK
npx openapi-ts -i ./openapi.json -o ./sdk
Or in a build script:
// scripts/generate-sdk.ts
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { contract } from '../src/contract'
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
})
const spec = generator.generate(contract, {
info: { title: 'My API', version: '1.0.0' },
servers: [{ url: 'https://api.example.com' }],
})
await Bun.write('./openapi.json', JSON.stringify(spec, null, 2))
TL;DR: Dynamic via OpenAPIReferencePlugin is the recommended approach. Build-time is only for publishing/CI needs.
cool! based on all of this: give me a tl;dr final answer so i can copy it over to my local coding agent
#oRPC + Next.js ā Minimal Setup
##File Structure
src/
āāā contract.ts # Schemas + API contract
āāā server.ts # Implementation
āāā client.ts # Frontend client
āāā app/api/[[...rest]]/route.ts # REST API + Swagger UI
##Dependencies
pnpm add @orpc/contract @orpc/server @orpc/openapi @orpc/client @orpc/zod @orpc/json-schema zod
##src/contract.ts
import { oc } from '@orpc/contract'
import * as z from 'zod'
// Schemas
export const TodoSchema = z.object({
id: z.number().int(),
title: z.string(),
completed: z.boolean(),
})
export const CreateTodoSchema = z.object({
title: z.string().min(1),
})
// Contract
export const contract = {
todos: {
list: oc
.route({ method: 'GET', path: '/todos', summary: 'List todos' })
.input(z.object({ limit: z.number().int().default(10) }))
.output(z.array(TodoSchema)),
create: oc
.route({ method: 'POST', path: '/todos', summary: 'Create todo' })
.input(CreateTodoSchema)
.output(TodoSchema),
get: oc
.route({ method: 'GET', path: '/todos/{id}', summary: 'Get todo' })
.input(z.object({ id: z.number().int() }))
.output(TodoSchema)
.errors({ NOT_FOUND: { message: 'Todo not found' } }),
},
}
##src/server.ts
import { implement } from '@orpc/server'
import { contract } from './contract'
let todos = [{ id: 1, title: 'Learn oRPC', completed: false }]
export const router = implement(contract).$router({
todos: {
list: ({ input }) => todos.slice(0, input.limit),
create: ({ input }) => {
const todo = { id: todos.length + 1, ...input, completed: false }
todos.push(todo)
return todo
},
get: ({ input, errors }) => {
const todo = todos.find(t => t.id === input.id)
if (!todo) throw errors.NOT_FOUND()
return todo
},
},
})
##src/app/api/[[...rest]]/route.ts
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { router } from '@/server'
import { TodoSchema, CreateTodoSchema } from '@/contract'
const handler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [new ZodToJsonSchemaConverter()],
specGenerateOptions: {
info: { title: 'My API', version: '1.0.0' },
commonSchemas: {
Todo: { schema: TodoSchema },
CreateTodo: { schema: CreateTodoSchema },
},
},
}),
],
})
async function handleRequest(request: Request) {
const { response } = await handler.handle(request, { prefix: '/api', context: {} })
return response ?? new Response('Not found', { status: 404 })
}
export { handleRequest as GET, handleRequest as POST, handleRequest as PUT, handleRequest as DELETE }
##src/client.ts
import type { router } from './server'
import type { RouterClient } from '@orpc/server'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
const link = new RPCLink({
url: typeof window !== 'undefined' ? `${window.location.origin}/api` : 'http://localhost:3000/api',
})
export const client: RouterClient<typeof router> = createORPCClient(link)
##What You Get
| URL | Description |
|---|---|
/api/todos | REST endpoints |
/api/spec.json | OpenAPI spec (dynamic) |
/api/reference | Swagger UI |
##Error Handling
// In contract ā define typed errors:
.errors({ NOT_FOUND: { message: 'Not found', data: z.object({ id: z.number() }) } })
// In handler ā throw with type safety:
throw errors.NOT_FOUND({ data: { id: input.id } })
// Or simple approach anywhere:
import { ORPCError } from '@orpc/server'
throw new ORPCError('NOT_FOUND', { message: 'Not found' })
// Client-side:
import { isORPCError } from '@orpc/client'
try {
await client.todos.get({ id: 999 })
} catch (e) {
if (isORPCError(e)) console.log(e.code, e.message, e.data)
}
##Generate SDK for External Projects
# Grab spec from running server
curl http://localhost:3000/api/spec.json > openapi.json
# Generate SDK with any OpenAPI codegen tool
npx @hey-api/openapi-ts -i ./openapi.json -o ./sdk
END OF POST