Building Type-Safe APIs with ORPC and Next.js 15 in 2026

If you love tRPC but wish it shipped first-class OpenAPI, REST compatibility, and a cleaner contract-first workflow, ORPC is the library that finally delivers. In 2026 it has quietly become the go-to choice for teams who want end-to-end type safety without locking themselves into a single transport or framework.
This tutorial walks you through building a production-ready ORPC API inside a Next.js 15 App Router project. By the end, you will have type-safe procedures, a React client powered by TanStack Query, authentication middleware, error handling, and auto-generated OpenAPI documentation — all in under 30 minutes of hands-on work.
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (ORPC uses modern
ReadableStreamAPIs) - Bun or pnpm (examples use pnpm, but either works)
- Familiarity with TypeScript and React
- Basic knowledge of Next.js App Router
- A code editor with TypeScript support (VS Code recommended)
What You'll Build
A small task manager API exposing:
task.list— paginated list of taskstask.create— create a task with Zod validationtask.update— partial update protected by middlewaretask.delete— delete with authorization check
The same procedures will be consumed in three different ways: from a React component, from a plain fetch call hitting the REST surface, and from an OpenAPI viewer generated automatically by ORPC.
Why ORPC Instead of tRPC?
ORPC (pronounced "oh-arpec") keeps everything developers love about tRPC — inferred types, middleware chains, typed errors — and adds three things that mattered enough to justify a new library:
- Contract-first schemas. You can define a procedure contract in one package and implement it in another, which is perfect for monorepos where the mobile app is not yet using Next.js.
- Dual transport. Every procedure is callable through both RPC and REST. Web clients use the binary-friendly RPC transport for speed; third parties hit the REST endpoint and read OpenAPI docs.
- Framework agnosticism. The same router runs on Next.js, Hono, Elysia, Bun, Cloudflare Workers, or a plain Node server. Migrating later becomes a one-line change.
Step 1: Create the Next.js Project
Start from a fresh Next.js 15 project with the App Router and TypeScript:
pnpm create next-app@latest orpc-demo --typescript --app --tailwind --src-dir
cd orpc-demoAnswer No to ESLint if you plan to use Biome later, and No to Turbopack for dev parity with production.
Step 2: Install ORPC and Related Packages
ORPC is split into focused packages so you only pay for what you import.
pnpm add @orpc/server @orpc/client @orpc/contract @orpc/openapi
pnpm add @orpc/react-query @tanstack/react-query
pnpm add zodThe @orpc/contract package is optional but recommended — it lets you define schemas once and reuse them on the server and the client.
Step 3: Define Your Contract
Create a new file that describes the shape of every procedure. Think of this as your TypeScript-native alternative to an OpenAPI YAML file.
// src/server/contract.ts
import { oc } from '@orpc/contract'
import { z } from 'zod'
const TaskSchema = z.object({
id: z.string().uuid(),
title: z.string().min(1).max(120),
done: z.boolean(),
createdAt: z.date(),
})
export const contract = oc.router({
task: {
list: oc
.route({ method: 'GET', path: '/tasks' })
.input(z.object({ cursor: z.string().optional(), limit: z.number().int().min(1).max(100).default(20) }))
.output(z.object({ items: z.array(TaskSchema), nextCursor: z.string().nullable() })),
create: oc
.route({ method: 'POST', path: '/tasks' })
.input(z.object({ title: z.string().min(1).max(120) }))
.output(TaskSchema),
update: oc
.route({ method: 'PATCH', path: '/tasks/{id}' })
.input(z.object({ id: z.string().uuid(), title: z.string().optional(), done: z.boolean().optional() }))
.output(TaskSchema),
delete: oc
.route({ method: 'DELETE', path: '/tasks/{id}' })
.input(z.object({ id: z.string().uuid() }))
.output(z.object({ success: z.literal(true) })),
},
})
export type Contract = typeof contractNotice that each procedure gets a REST path. ORPC will serve both the RPC binary format and the REST path from the same router — no duplication.
Step 4: Implement the Router
Now wire the business logic to the contract. For this tutorial we'll use an in-memory store, but swap it for Prisma, Drizzle, or Supabase in your real project.
// src/server/router.ts
import { implement } from '@orpc/server'
import { randomUUID } from 'node:crypto'
import { contract } from './contract'
type Task = {
id: string
title: string
done: boolean
createdAt: Date
}
const store = new Map<string, Task>()
const os = implement(contract)
const taskListHandler = os.task.list.handler(async ({ input }) => {
const items = Array.from(store.values()).slice(0, input.limit)
return { items, nextCursor: null }
})
const taskCreateHandler = os.task.create.handler(async ({ input }) => {
const task: Task = {
id: randomUUID(),
title: input.title,
done: false,
createdAt: new Date(),
}
store.set(task.id, task)
return task
})
const taskUpdateHandler = os.task.update.handler(async ({ input, errors }) => {
const existing = store.get(input.id)
if (!existing) throw errors.NOT_FOUND({ message: 'Task not found' })
const updated = { ...existing, ...input }
store.set(updated.id, updated)
return updated
})
const taskDeleteHandler = os.task.delete.handler(async ({ input, errors }) => {
if (!store.has(input.id)) throw errors.NOT_FOUND({ message: 'Task not found' })
store.delete(input.id)
return { success: true as const }
})
export const router = os.router({
task: {
list: taskListHandler,
create: taskCreateHandler,
update: taskUpdateHandler,
delete: taskDeleteHandler,
},
})
export type Router = typeof routerThe implement(contract) call guarantees at compile time that every handler matches the contract — rename a field and TypeScript will instantly flag the broken handler.
Step 5: Mount ORPC on a Next.js Route Handler
App Router makes this trivial. Create a catch-all route that forwards every request to the ORPC handler.
// src/app/api/[[...rest]]/route.ts
import { RPCHandler } from '@orpc/server/fetch'
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { router } from '@/server/router'
const rpcHandler = new RPCHandler(router)
const openapiHandler = new OpenAPIHandler(router)
async function handle(request: Request) {
const url = new URL(request.url)
if (url.pathname.startsWith('/api/rpc')) {
const { response } = await rpcHandler.handle(request, { prefix: '/api/rpc' })
if (response) return response
}
const { response } = await openapiHandler.handle(request, { prefix: '/api' })
if (response) return response
return new Response('Not found', { status: 404 })
}
export const GET = handle
export const POST = handle
export const PATCH = handle
export const DELETE = handleNow /api/rpc/task.list serves binary RPC calls and /api/tasks serves the exact same data over REST. One router, two transports.
Step 6: Build the Client
Create a client-side helper that reuses the same Router type so every call is auto-completed and type-checked.
// src/lib/orpc-client.ts
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { Router } from '@/server/router'
const link = new RPCLink({ url: '/api/rpc' })
export const orpc = createORPCClient<Router>(link)Thanks to the exported Router type, orpc.task.create({ title: 'Ship ORPC demo' }) is fully typed — inputs, outputs, and possible errors are all inferred.
Step 7: Connect TanStack Query
ORPC ships first-class TanStack Query bindings. Install the provider at the root of your App Router tree.
// src/app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState, type ReactNode } from 'react'
export function Providers(props: { children: ReactNode }) {
const [client] = useState(() => new QueryClient())
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
}Wrap the root layout:
// src/app/layout.tsx
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Providers>{children}</Providers>
</body>
</html>
)
}Now build a task list component that reads and mutates tasks:
// src/app/page.tsx
'use client'
import { createORPCReactQueryUtils } from '@orpc/react-query'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { orpc } from '@/lib/orpc-client'
const $orpc = createORPCReactQueryUtils(orpc)
export default function HomePage() {
const qc = useQueryClient()
const [title, setTitle] = useState('')
const tasks = useQuery($orpc.task.list.queryOptions({ input: { limit: 20 } }))
const createTask = useMutation({
...$orpc.task.create.mutationOptions(),
onSuccess: () => qc.invalidateQueries({ queryKey: $orpc.task.list.key() }),
})
return (
<main className="mx-auto max-w-xl p-8">
<h1 className="text-2xl font-bold">Tasks</h1>
<form
onSubmit={(e) => {
e.preventDefault()
createTask.mutate({ title })
setTitle('')
}}
className="mt-4 flex gap-2"
>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
className="flex-1 rounded border px-3 py-2"
placeholder="What needs doing?"
/>
<button className="rounded bg-black px-4 py-2 text-white">Add</button>
</form>
<ul className="mt-6 space-y-2">
{tasks.data?.items.map((task) => (
<li key={task.id} className="rounded border p-3">
{task.title}
</li>
))}
</ul>
</main>
)
}If you rename title to name in the contract, TypeScript will highlight the broken mutation call before you even save the file.
Step 8: Add Authentication Middleware
Protecting routes is where ORPC feels especially polished. Middleware composes like Express middleware but keeps the return type fully inferred.
// src/server/middleware.ts
import { os } from './router'
import { ORPCError } from '@orpc/server'
import { cookies } from 'next/headers'
export const authed = os.middleware(async ({ context, next }) => {
const session = (await cookies()).get('session')?.value
if (!session) throw new ORPCError('UNAUTHORIZED', { message: 'Sign in to continue' })
const user = { id: session, role: 'member' as const }
return next({ context: { ...context, user } })
})Apply it to only the mutating procedures:
const taskCreateHandler = os.task.create
.use(authed)
.handler(async ({ input, context }) => {
const task: Task = {
id: randomUUID(),
title: `${input.title} (by ${context.user.id})`,
done: false,
createdAt: new Date(),
}
store.set(task.id, task)
return task
})Callers now get a typed UNAUTHORIZED error they can narrow with if (error.code === 'UNAUTHORIZED').
Step 9: Auto-Generate OpenAPI Docs
The @orpc/openapi package can emit a spec from the very same contract, which means the documentation can never drift out of sync with the implementation.
// src/app/api/openapi/route.ts
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod'
import { contract } from '@/server/contract'
const generator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
})
export async function GET() {
const spec = await generator.generate(contract, {
info: { title: 'Tasks API', version: '1.0.0' },
servers: [{ url: '/api' }],
})
return Response.json(spec)
}Pair it with Scalar or Swagger UI to get a beautiful docs page at /api/openapi.
Step 10: Error Handling at the Client
Because errors are part of the contract, clients handle them without guesswork:
import { isDefinedError } from '@orpc/client'
try {
await orpc.task.update({ id: 'bad-id', title: 'Nope' })
} catch (error) {
if (isDefinedError(error) && error.code === 'NOT_FOUND') {
console.warn('Task disappeared — refresh the list')
} else {
throw error
}
}No more parsing generic Error objects or praying the status code matches what the backend sent last Friday.
Testing Your Implementation
Run the dev server and try it end to end:
pnpm devThen exercise both transports:
curl http://localhost:3000/api/tasks
curl -X POST http://localhost:3000/api/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Finish ORPC tutorial"}'Open http://localhost:3000 in your browser and add a task from the form. The React Query cache should invalidate automatically and the new task should appear within a blink.
Troubleshooting
"Cannot find module '@orpc/server/fetch'" — make sure you are on Node 20 or newer. Node 18 does not ship the global ReadableStream that ORPC uses.
Types are any on the client — verify that tsconfig.json has "moduleResolution": "Bundler" and that the Router type is exported with export type, not a bare export.
CORS errors in production — ORPC does not set CORS headers. Use Next.js middleware.ts or a per-route handler to add them when your RPC endpoint lives on a different origin than the client.
OpenAPI does not update after a schema change — the spec is generated at request time, so a hard reload is enough. If you cache the response, remember to bust the cache.
Next Steps
- Swap the in-memory store for Prisma using the Prisma Next.js tutorial.
- Add optimistic updates with TanStack Query's
onMutatecallback. - Deploy to Cloudflare Workers — the same router runs unchanged thanks to the Fetch adapter.
- Generate a typed SDK for your mobile app by re-exporting the contract from a shared package.
- Layer rate limiting using Arcjet or Upstash.
Conclusion
ORPC in Next.js 15 hits a sweet spot that tRPC, REST, and plain GraphQL each miss individually. You get contract-driven type safety across the wire, a first-class OpenAPI surface for external consumers, and a runtime that follows you from Vercel to Cloudflare to Bun without rewrites.
The real magic is the way the same file — your contract — becomes the input schema, the output type, the middleware signature, the OpenAPI document, and the React Query hook. That single source of truth is what makes teams faster as the codebase grows, and it is the reason ORPC has become our default recommendation for new Next.js APIs in 2026.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.

Medusa.js 2.0 — Build a Headless E-commerce Store with Next.js (2026)
Learn how to build a complete headless e-commerce store using Medusa.js 2.0 and Next.js. From product catalog to checkout, this tutorial covers the full stack with TypeScript.

Build Transactional Emails with Resend and React Email in Next.js
Learn how to build beautiful, type-safe transactional emails using React Email and Resend in a Next.js application. This tutorial covers email template design, preview workflows, sending via API routes, and production deployment.