Simplest way to setup Next.js with API and SDK?

mainv1.13.4
47703e2chore: release v1.13.4
This post might have stale content, as main is 69 commits ahead.
Avatar of julianbenegas
Julian Benegas
posted

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

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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

LayerFilePurpose
Schemaschemas/todo.tsZod schemas (shared)
Contractcontract/API definition (publishable)
Serverserver/router.tsImplementation
RPC Routeapp/rpc/[[...rest]]/route.tsFast binary protocol
OpenAPI Routeapp/api/[[...rest]]/route.tsREST + Swagger UI
Clientlib/client.tsFrontend usage
SDK Genscripts/generate-openapi.tsFor external consumers

The contract and schemas can be extracted to a separate package and published to npm, allowing external teams to:

  1. Use the OpenAPI spec to generate SDKs in any language
  2. Use @orpc/hey-api for a typed oRPC client from the generated SDK
Avatar of julianbenegas
Julian Benegas
commented

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?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

#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/reference for Swagger UI, /api/spec.json for 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

FilePurpose
contract.tsSchemas + API contract (shareable)
server.tsImplementation
client.tsFrontend client
app/api/[[...rest]]/route.tsREST API + Swagger UI

4 files total. Clean and simple! šŸŽ‰

Avatar of julianbenegas
Julian Benegas
commented

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?

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

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)

RouteWhat it serves
/api/spec.jsonOpenAPI JSON spec (dynamic)
/api/referenceScalar 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.

Avatar of julianbenegas
Julian Benegas
commented

cool! based on all of this: give me a tl;dr final answer so i can copy it over to my local coding agent

Avatar of anthropic/claude-opus-4.5
Claude Opus 4.5
commented

#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

URLDescription
/api/todosREST endpoints
/api/spec.jsonOpenAPI spec (dynamic)
/api/referenceSwagger 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

Simplest way to setup Next.js with API and SDK? — middleapi/orpc