How to generate OpenAPI schema in Next.js build process?
how do i setup my next.js app so that it generates an openapi schema as part of the build/deploy process? i'm deploying to vercel btw
I now have all the information needed to provide a comprehensive answer. Based on my research, there are two main approaches for setting up OpenAPI schema generation with oRPC in a Next.js app:
##Two Approaches to OpenAPI Schema Generation
###1. Runtime Generation with OpenAPIReferencePlugin (Recommended for Most Cases)
This is the simplest approach and works great with Vercel. The OpenAPI spec is generated on-demand when someone requests it.
Setup in src/app/api/[[...rest]]/route.ts:
import { router } from '@/routers'
import { OpenAPIHandler } from '@orpc/openapi/fetch'
import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4' // or '@orpc/zod' for Zod v3
const openAPIHandler = new OpenAPIHandler(router, {
plugins: [
new OpenAPIReferencePlugin({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
specGenerateOptions: {
info: {
title: 'My API',
version: '1.0.0',
},
// Optional: servers is auto-inferred from request if not specified
servers: [
{ url: 'https://your-app.vercel.app/api' },
],
},
}),
],
})
async function handleRequest(request: Request) {
const { response } = await openAPIHandler.handle(request, {
prefix: '/api',
context: {},
})
return response ?? new Response('Not found', { status: 404 })
}
export const GET = handleRequest
export const POST = handleRequest
export const PUT = handleRequest
export const PATCH = handleRequest
export const DELETE = handleRequest
Result:
- Docs UI:
https://your-app.vercel.app/api/(Scalar/Swagger) - OpenAPI JSON:
https://your-app.vercel.app/api/spec.json
This generates the spec at runtime - every request to /api/spec.json generates the spec on the fly. For most use cases, this is perfectly fine and even has the benefit of always being up-to-date.
###2. Build-Time Generation (Static File)
If you need to generate the spec as a static file during your build/deploy process (e.g., for CI pipelines, external tooling, or caching), you can create a script:
Create scripts/generate-openapi.ts:
import { writeFileSync } from 'node:fs'
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'
import { router } from '../src/routers' // adjust path to your router
const generator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter(),
],
})
async function main() {
const spec = await generator.generate(router, {
info: {
title: 'My API',
version: '1.0.0',
},
servers: [
{ url: 'https://your-app.vercel.app/api' },
],
})
writeFileSync('./public/openapi.json', JSON.stringify(spec, null, 2))
console.log('✅ OpenAPI spec generated at public/openapi.json')
}
main()
Add to package.json:
{
"scripts": {
"generate:openapi": "tsx scripts/generate-openapi.ts",
"build": "npm run generate:openapi && next build"
}
}
Vercel Configuration:
Since Vercel runs npm run build (or your specified build command) during deployment, the OpenAPI spec will be generated as part of the build process and placed in public/openapi.json. It will then be served as a static file.
Alternatively, you can configure the build command in vercel.json:
{
"buildCommand": "npm run generate:openapi && next build"
}
##Which Approach to Choose?
| Approach | Pros | Cons |
|---|---|---|
| Runtime (Plugin) | Always up-to-date, no build step needed, simpler setup | Slight overhead on first request (cached after) |
| Build-Time (Script) | Static file, can be used in CI/CD pipelines, no runtime overhead | Must rebuild to update, more setup required |
For Vercel deployments, the runtime approach with OpenAPIReferencePlugin is typically the best choice since:
- It's simpler to set up
- The spec auto-infers the server URL from the request
- Vercel's edge caching handles repeated requests efficiently
The build-time approach is better if you need to:
- Feed the spec to external tools during CI
- Generate client SDKs during the build
- Ensure the spec file exists before deployment completes
how do i use it in the client after that? in say option 1?
Now I have a complete picture. Here's how to use the OpenAPI client:
##Using the Client with OpenAPILink
With Option 1 (runtime generation via OpenAPIReferencePlugin), you don't actually need the generated spec.json for your oRPC client - the client uses the contract/router types directly for type safety. The spec.json is mainly for documentation (Swagger/Scalar UI) or for external tools.
###Client Setup
Install the client package:
npm install @orpc/openapi-client@latest @orpc/client@latest
Create src/lib/orpc.ts:
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { ContractRouterClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { router } from '@/routers' // your router (acts as contract)
// Create the link pointing to your API
const link = new OpenAPILink(router, {
url: typeof window !== 'undefined'
? `${window.location.origin}/api`
: 'http://localhost:3000/api',
headers: () => ({
Authorization: `Bearer ${getToken()}`, // if you need auth
}),
})
// Create the typed client
// JsonifiedClient wraps types to account for JSON serialization (e.g., Date -> string)
export const client: JsonifiedClient<ContractRouterClient<typeof router>> = createORPCClient(link)
Use it in your components:
// In a React component or server component
const planets = await client.planet.list({ limit: 10 })
// ^? JsonifiedClient transforms Date fields to strings, etc.
###With TanStack Query (React Query)
If you're using React Query:
// src/lib/orpc.ts
import type { JsonifiedClient } from '@orpc/openapi-client'
import type { ContractRouterClient } from '@orpc/contract'
import { createORPCClient } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'
import { createRouterUtils } from '@orpc/tanstack-query'
import { router } from '@/routers'
const link = new OpenAPILink(router, {
url: typeof window !== 'undefined'
? `${window.location.origin}/api`
: 'http://localhost:3000/api',
})
export const client: JsonifiedClient<ContractRouterClient<typeof router>> = createORPCClient(link)
// Create TanStack Query utilities
export const orpc = createRouterUtils(client)
In your component:
'use client'
import { orpc } from '@/lib/orpc'
export function PlanetList() {
const { data, isLoading } = orpc.planet.list.useQuery({
input: { limit: 10 }
})
if (isLoading) return <div>Loading...</div>
return (
<ul>
{data?.map(planet => (
<li key={planet.id}>{planet.name}</li>
))}
</ul>
)
}
##Key Points
| Aspect | Details |
|---|---|
| Type Source | Types come from your router/contract, not the spec.json |
JsonifiedClient | Wraps types to reflect JSON serialization (e.g., Date → string) |
spec.json Purpose | For Swagger/Scalar UI docs, or external tools (not needed for oRPC client) |
| URL | Points to your API prefix (e.g., /api) |
##Alternative: RPCLink (Simpler, but not OpenAPI)
If you don't need OpenAPI compliance on the wire (just the spec for docs), you can use RPCLink instead, which is simpler and supports more data types natively:
import { RPCLink } from '@orpc/client/fetch'
const link = new RPCLink({
url: `${window.location.origin}/rpc`,
})
But this uses oRPC's custom RPC protocol, not RESTful OpenAPI routes. Use OpenAPILink if you want your client calls to hit proper REST endpoints like GET /api/planets instead of POST /rpc.
END OF POST