# oRPC > Build APIs that are typesafe end to end, with OpenAPI included # Astro Adapter Source: https://orpc.dev/docs/adapters/astro [Astro](https://astro.build/) is a JavaScript web framework optimized for building fast, content-driven websites. Its endpoints follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts title="src/pages/rpc/[...rest].ts" import type { APIRoute } from 'astro' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) export const prerender = false export const ALL: APIRoute = async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) } ``` :::info `prerender = false` makes this an on-demand route, so building your site requires an [adapter](https://docs.astro.build/en/guides/on-demand-rendering/) such as `@astrojs/node`. ::: :::warning Astro's [CSRF protection](https://docs.astro.build/en/reference/configuration-reference/#securitycheckorigin) can reject oRPC requests from non-browser or cross-origin clients, such as file uploads, with a `403` response before the route runs. ::: :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ## Optimize SSR To reduce HTTP requests and improve latency during SSR, you can use a [server-side client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimizing SSR](/docs/recipes/optimizing-ssr) for more details. ```ts title="src/lib/orpc.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' if (import.meta.env.SSR) { await import('./orpc.server') } declare global { var $client: RouterClient | undefined } const link = new RPCLink({ url: '/rpc', origin: () => { if (typeof window === 'undefined') { throw new Error('This link is not allowed on the server side.') } return window.location.origin }, }) /** * Fall back to a browser client when no SSR client is registered. */ export const client: RouterClient = globalThis.$client ?? createORPCClient(link) ``` ```ts title="src/lib/orpc.server.ts" import { createRouterClient } from '@orpc/server' globalThis.$client = createRouterClient(router, { context: {} // Provide initial context if needed }) ``` --- # AWS Lambda Adapter Source: https://orpc.dev/docs/adapters/aws-lambda :::warning This adapter requires the Lambda Node.js runtime with [response streaming](https://docs.aws.amazon.com/lambda/latest/dg/configuration-response-streaming.html) enabled, so handlers must be wrapped with `awslambda.streamifyResponse`. ::: ## Server Usage ```ts RPC import type { APIGatewayProxyEventV2, AwsLambdaGlobal } from '@standard-server/aws-lambda' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/aws-lambda' import { CORSHandlerPlugin } from '@orpc/server/plugins' declare const awslambda: AwsLambdaGlobal const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export const rpc = awslambda.streamifyResponse(async (event, responseStream, context) => { const { matched } = await handler.handle(event, responseStream, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return } awslambda.HttpResponseStream.from(responseStream, { statusCode: 404, headers: {}, cookies: [], }).end('Not found') }) ``` ```ts OpenAPI import type { APIGatewayProxyEventV2, AwsLambdaGlobal } from '@standard-server/aws-lambda' import { OpenAPIHandler } from '@orpc/openapi/aws-lambda' import { onError } from '@orpc/server' import { CORSHandlerPlugin } from '@orpc/server/plugins' declare const awslambda: AwsLambdaGlobal const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export const api = awslambda.streamifyResponse(async (event, responseStream, context) => { const { matched } = await handler.handle(event, responseStream, { prefix: '/api', context: {} // Provide initial context if needed }) if (matched) { return } awslambda.HttpResponseStream.from(responseStream, { statusCode: 404, headers: {}, cookies: [], }).end('Not found') }) ``` :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Event Stream Options You can configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `sendStandardResponse.eventStream` options when creating the handler. ```ts const handler = new OpenAPIHandler(router, { sendStandardResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` --- # Browser Adapter Source: https://orpc.dev/docs/adapters/browser Enable typesafe communication between browser scripts using the [Message Port Adapter](/docs/adapters/message-port). ## Between Extension Scripts To set up communication between scripts in a browser extension (e.g. background, content, popup), configure one script to listen for connections and upgrade them, and another to initiate the connection. :::warning The browser extension [Message Passing API](https://developer.chrome.com/docs/extensions/develop/concepts/messaging) does not support transferring binary data, which means oRPC features like `File` and `Blob` cannot be used natively. However, you can temporarily work around this limitation by extending the [RPC Serializer](/docs/rpc/serializer) to encode binary data as `base64`. ::: ```ts server import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) browser.runtime.onConnect.addListener((port) => { handler.upgrade(port, { context: {} // Provide initial context if needed }) }) ``` ```ts client import { RPCLink } from '@orpc/client/message-port' const port = browser.runtime.connect() const link = new RPCLink({ port, }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: ## Window to Window To enable communication between two window contexts (e.g. parent and popup), one must listen and upgrade the port, and the other must initiate the connection. ```ts opener import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) window.addEventListener('message', (event) => { if (event.data instanceof MessagePort) { handler.upgrade(event.data, { context: {} // Provide initial context if needed }) event.data.start() } }) window.open('/example/popup', 'popup', 'width=680,height=520') ``` ```ts popup import { RPCLink } from '@orpc/client/message-port' const { port1: serverPort, port2: clientPort } = new MessageChannel() window.opener.postMessage(serverPort, '*', [serverPort]) const link = new RPCLink({ port: clientPort, }) clientPort.start() ``` ## Advanced Relay Pattern In some advanced cases, direct communication between scripts isn't possible. For example, a content script running in the ["MAIN" world](https://developer.chrome.com/docs/extensions/reference/manifest/content-scripts#world-timings) cannot directly communicate with the background script using `browser.runtime` or `chrome.runtime` APIs. To work around this, you can use a **relay pattern**: an additional content script running in the **"ISOLATED" (default) world** relays messages between the two contexts, enabling communication where direct access is restricted. ```ts relay window.addEventListener('message', (event) => { if (event.data instanceof MessagePort) { const port = browser.runtime.connect() // Relay `message` and `close/disconnect` events between the MessagePort and runtime.Port event.data.addEventListener('message', (event) => { port.postMessage(event.data) }) event.data.addEventListener('close', () => { port.disconnect() }) port.onMessage.addListener((message) => { event.data.postMessage(message) }) port.onDisconnect.addListener(() => { event.data.close() }) event.data.start() } }) ``` ```ts server import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) browser.runtime.onConnect.addListener((port) => { handler.upgrade(port, { context: {} // Provide initial context if needed }) }) ``` ```ts client import { RPCLink } from '@orpc/client/message-port' const { port1: serverPort, port2: clientPort } = new MessageChannel() window.postMessage(serverPort, '*', [serverPort]) const link = new RPCLink({ port: clientPort, }) clientPort.start() ``` --- # Cloudflare Workers Adapter Source: https://orpc.dev/docs/adapters/cloudflare-workers [Cloudflare Workers](https://developers.cloudflare.com/workers/) follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts RPC import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export default { async fetch(request, env, ctx) { const { matched, response } = await handler.handle(request, { prefix: '/rpc', context: { env, ctx } // Provide initial context if needed }) if (matched) { return response } return new Response('Not found', { status: 404 }) }, } satisfies ExportedHandler ``` ```ts OpenAPI import { OpenAPIHandler } from '@orpc/openapi/fetch' import { onError } from '@orpc/server' import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export default { async fetch(request, env, ctx) { const { matched, response } = await handler.handle(request, { prefix: '/api', context: { env, ctx } // Provide initial context if needed }) if (matched) { return response } return new Response('Not found', { status: 404 }) }, } satisfies ExportedHandler ``` ## Compatibility Flags oRPC forwards `request.signal` to every procedure call so handlers can stop work when the client disconnects. Workers only abort that signal when the [`enable_request_signal`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#enable-requestsignal-for-incoming-requests) flag is set, so add it to your Wrangler configuration: ```jsonc title="wrangler.jsonc" { "compatibility_date": "2026-07-01", "compatibility_flags": ["enable_request_signal"] } ``` If your `compatibility_date` is before `2026-03-03`, also add [`unhandled_rejection_after_microtask_checkpoint`](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#defer-unhandled-rejection-processing-to-after-microtask-checkpoint). Without it, Workers can report false `unhandledrejection` errors for promises that are handled a microtask later. ## Traces Use the [Cloudflare Workers Traces integration](/docs/integrations/cloudflare-traces) to record oRPC spans in Workers Traces. --- # Electron Adapter Source: https://orpc.dev/docs/adapters/electron Establish typesafe communication between processes in [Electron](https://www.electronjs.org/) using the [Message Port Adapter](/docs/adapters/message-port). Before you start, we recommend reading the [MessagePorts in Electron](https://www.electronjs.org/docs/latest/tutorial/message-ports) guide. ## Main Process Listen for a port sent from the renderer, then upgrade it: ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' import { app, ipcMain } from 'electron' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) app.whenReady().then(() => { ipcMain.on('start-orpc-server', async (event) => { const [serverPort] = event.ports handler.upgrade(serverPort, { context: {} // Provide initial context if needed }) serverPort.start() }) }) ``` :::info Channel `start-orpc-server` is arbitrary. You can use any name that fits your needs. ::: ## Preload Process Receive the port from the renderer and forward it to the main process: ```ts import { ipcRenderer } from 'electron' window.addEventListener('message', (event) => { if (event.data === 'start-orpc-client') { const [serverPort] = event.ports ipcRenderer.postMessage('start-orpc-server', null, [serverPort]) } }) ``` ## Renderer Process Create a `MessageChannel`, send one port to the preload script, and use the other to initialize the client link: ```ts import { RPCLink } from '@orpc/client/message-port' const { port1: clientPort, port2: serverPort } = new MessageChannel() window.postMessage('start-orpc-client', '*', [serverPort]) const link = new RPCLink({ port: clientPort, }) clientPort.start() ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: --- # Elysia Adapter Source: https://orpc.dev/docs/adapters/elysia [Elysia](https://elysiajs.com/) is a high-performance web framework for [Bun](https://bun.com/) that adheres to the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { Elysia } from 'elysia' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) const app = new Elysia() .all('/rpc*', async ({ request }: { request: Request }) => { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) }, { parse: 'none' // Skip Elysia's body parsing; oRPC reads the raw request itself }) .listen(3000) console.log('Elysia is running at http://localhost:3000') ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: --- # Expo Adapter Source: https://orpc.dev/docs/adapters/expo [Expo](https://expo.dev/) is the supported way to run oRPC in a React Native app. It installs the browser APIs oRPC depends on, which bare React Native does not provide, so JSON calls, [AsyncIteratorObject](/docs/async-iterator-object), and streamed downloads work in a plain Expo project with no polyfill and no Metro configuration. [Some `File` and `Blob` cases](#feature-support) need one extra package. Two of those matter most: - **Web Streams.** oRPC needs `ReadableStream` for every procedure call and `TransformStream` for [AsyncIteratorObject](/docs/async-iterator-object); without them every call throws `ReferenceError: ReadableStream is not defined`. Expo installs `ReadableStream`, `WritableStream`, and `TransformStream` as native globals from [SDK 53](https://github.com/expo/expo/pull/36407). - **A streaming `fetch`.** React Native's built-in `fetch` is an `XMLHttpRequest` polyfill with no `response.body`, which rules out [AsyncIteratorObject](/docs/async-iterator-object) and streamed downloads. [`expo/fetch`](https://docs.expo.dev/versions/latest/sdk/expo/#fetch) is native-backed and streams properly. From **SDK 56** it replaces the global `fetch` on Android and iOS, so there is nothing to wire up. :::tip Use **SDK 56 or later** to get everything working out of the box. SDK 53 to 55 work too, but you must pass `expo/fetch` to the link yourself, as shown [below](#expo-sdk-53-to-55). ::: ## Fetch Link ```ts title="utils/orpc.ts" import { RPCLink } from '@orpc/client/fetch' export const link = new RPCLink({ origin: process.env.EXPO_PUBLIC_SERVER_URL, url: '/rpc', headers: async ({ context }) => ({ 'x-api-key': context?.something ?? '', }), }) ``` :::info The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [OpenAPILink](/docs/openapi/link), or a custom one. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: ### Expo SDK 53 to 55 On these versions the global `fetch` is still React Native's non-streaming polyfill, so pass `expo/fetch` to the link explicitly. Everything else stays the same. ```ts import { fetch as expoFetch } from 'expo/fetch' import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ origin: process.env.EXPO_PUBLIC_SERVER_URL, url: '/rpc', fetch: (url, init) => expoFetch(url, init), }) ``` :::warning On SDK 56 and later you can opt back out of `expo/fetch` by setting `EXPO_PUBLIC_USE_RN_FETCH=1`. Doing so restores React Native's `XMLHttpRequest` polyfill and silently disables streaming, so `AsyncIteratorObject` and streamed responses stop working. ::: ## WebSocket Link Expo inherits React Native's `WebSocket`, which is backed by OkHttp on Android and SocketRocket on iOS and handles binary frames in both directions. ```ts import { RPCLink } from '@orpc/client/websocket' const link = new RPCLink({ connect: () => { const ws = new WebSocket('ws://localhost:3000') ws.binaryType = 'arraybuffer' return ws }, }) ``` :::warning Set `binaryType` to `'arraybuffer'` and never `'blob'`. React Native's `Blob` has no `arrayBuffer()`, `text()`, or `stream()` method, so oRPC cannot read the bytes back out of a blob message. Every binary frame is also base64-encoded to cross the native bridge, so prefer many small messages over one large payload. Sending or receiving a `File` or `Blob` over this link additionally needs the [Blob polyfill](#blob-polyfill). ::: ## Feature Support oRPC treats binary data two different ways, and the difference decides what works on Expo: - **Root level**, where a `File` or `Blob` is the entire input or output. oRPC sends it as the raw body, with no multipart involved. - **Nested**, where a `File` or `Blob` sits inside an object or array. oRPC packs it into a `FormData`, which travels as `multipart/form-data`. Expo never parses multipart, and the WebSocket link cannot even produce it, which is why every nested row below fails somewhere. Everything else depends on whether you install the [Blob polyfill](#blob-polyfill). | Feature | Fetch | Fetch + polyfill | WebSocket | WebSocket + polyfill | | --- | --- | --- | --- | --- | | [AsyncIteratorObject](/docs/async-iterator-object) download | Yes | Yes | Yes | Yes | | `AsyncIteratorObject` upload | Buffered | Buffered | Yes | Yes | | [`ReadableStream`](/docs/binary-data#readablestreamuint8array) download | Yes | Yes | Yes | Yes | | `ReadableStream` upload | Buffered | Buffered | Yes | Yes | | Root-level [`File` or `Blob`](/docs/binary-data#file-and-blob) download | No | Yes | No | Yes | | Root-level `File` or `Blob` upload | Yes | Yes | No | Yes | | Nested `File` or `Blob` download | No | No | No | No | | Nested `File` or `Blob` upload | No | Yes | No | No | Two things are worth knowing beyond the table: - **`expo/fetch` buffers every upload.** It accepts a streamed request body, whether that is an `AsyncIteratorObject` or a `ReadableStream`, but drains it into one buffer before the request starts. Nothing reaches the server until the stream finishes, and the whole payload sits in memory meanwhile. Downloads stream properly. The WebSocket link has no such limit: each chunk is its own frame, sent immediately, so uploads genuinely stream. It is the one place the WebSocket link beats fetch. - **The WebSocket link handles nested binary worse.** It runs `FormData` through the global `Response` in both directions, and Expo leaves that as React Native's implementation, which can neither read a `FormData` body as a blob nor parse a multipart one. The fetch link only has the decode half of that problem. ### Blob Polyfill React Native's `Blob` has no `arrayBuffer()`, `text()`, `bytes()`, or `stream()`, and `new Blob([arrayBuffer])` throws, so oRPC cannot read bytes back out of a downloaded blob. [`expo-blob`](https://www.npmjs.com/package/expo-blob) is Expo's spec-compliant implementation. It fixes every root-level case in the table above, plus nested uploads on the fetch link. ```package-install npx expo install expo-blob ``` It exports only `Blob`, so declare `File` yourself and install both as globals from your entry file: ```ts title="polyfill.ts" import { Blob, type BlobPart } from 'expo-blob' class File extends Blob { name: string lastModified: number webkitRelativePath = '' constructor(fileBits: BlobPart[] | Iterable, fileName: string, options?: FilePropertyBag) { super(fileBits, options) this.name = String(fileName) this.lastModified = options?.lastModified ?? Date.now() } override toString(): string { return '[object File]' } } Object.assign(globalThis, { Blob, File }) ``` :::warning `File` must extend the same class you install as `globalThis.Blob`, because oRPC classifies binary values with `instanceof Blob`. Keep `name` an own writable property, as above, so Expo's `FormData` can set a filename on it. Replacing the global `Blob` also affects code outside oRPC. `xhr.responseType = 'blob'` and `WebSocket` with `binaryType = 'blob'` still produce React Native blobs, which are no longer `instanceof Blob`. `URL.createObjectURL` and `FileReader` go the other way: they read React Native's internal blob data, so they throw when handed a polyfilled one. ::: ### Why nested binary fails A nested `File` or `Blob` is packed into a `FormData` and sent as `multipart/form-data`. Reading it back needs `Response.formData()`, and no `Response` on Expo can do that. Both `expo/fetch` and React Native handle only the simpler `application/x-www-form-urlencoded` format. Writing multipart works, but only on the fetch link, where `expo/fetch` does the encoding. The WebSocket link uses `Response` to write it too, so that link fails in both directions. | Link | Nested upload | Nested download | | --- | --- | --- | | Fetch | Works with the [Blob polyfill](#blob-polyfill) | Fails | | WebSocket | Fails | Fails | **The easy fix is to avoid multipart.** Keep files at the root of a procedure's input or output instead of inside an object. Or extend the [RPC JSON Serializer](/docs/rpc/serializer) to carry binary as `base64`. **The full fix is a `Response` that handles multipart.** For the WebSocket link, replace `globalThis.Response` with one that can both write and read it. For the fetch link only reading is missing, so extend the response that `expo/fetch` returns. ## Bare React Native oRPC does not support bare React Native out of the box. Without Expo you get neither the Web Streams globals oRPC needs nor a streaming `fetch`. You can get close by adding [web-streams-polyfill](https://github.com/MattiasBuelens/web-streams-polyfill) and loading it from your entry file, but `AsyncIteratorObject` and streamed downloads will still not work over the built-in `fetch`. React Native 0.78 is also a hard floor: oRPC uses a class static initialization block, Metro does not transpile it, and older Hermes cannot parse it. See [Requirements](/docs/requirements#react-native) for the full list of APIs oRPC needs. --- # Express.js Adapter Source: https://orpc.dev/docs/adapters/express [Express.js](https://expressjs.com/) is a popular Node.js framework for building web applications. oRPC integrates with it through the [Node HTTP Adapter](/docs/adapters/node-http). :::warning Express's [body-parser](https://expressjs.com/en/resources/middleware/body-parser.html) handles common request body types, and oRPC will use the parsed body if available. However, it doesn't support features like [Bracket Notation](/docs/openapi/bracket-notation), and in case you upload a file with `application/json`, it may be parsed as plain JSON instead of a `File`. To avoid these issues, register any body-parsing middleware **after** your oRPC middleware or only on routes that don't use oRPC. ::: ## Basic ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' import cors from 'cors' import express from 'express' const app = express() app.use(cors()) const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) app.use('/rpc{/*path}', async (req, res, next) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return } next() }) app.listen(3000, () => console.log('Server listening on port 3000')) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: --- # Fastify Adapter Source: https://orpc.dev/docs/adapters/fastify ## Server Usage ```ts RPC import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fastify' import { CORSHandlerPlugin } from '@orpc/server/plugins' import Fastify from 'fastify' const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) const app = Fastify() app.all('/rpc/*', async (req, reply) => { const { matched } = await handler.handle(req, reply, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return reply } return reply.status(404).send('Not found') }) app.listen({ port: 3000 }).then(() => console.log('Listening on port 3000')) ``` ```ts OpenAPI import { OpenAPIHandler } from '@orpc/openapi/fastify' import { onError } from '@orpc/server' import { CORSHandlerPlugin } from '@orpc/server/plugins' import Fastify from 'fastify' const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) const app = Fastify() app.all('/api/*', async (req, reply) => { const { matched } = await handler.handle(req, reply, { prefix: '/api', context: {} // Provide initial context if needed }) if (matched) { return reply } return reply.status(404).send('Not found') }) app.listen({ port: 3000 }).then(() => console.log('Listening on port 3000')) ``` :::tip Fastify only accepts content types it has a registered parser for, and parses request bodies itself. For the best oRPC experience, register a catch-all parser with `app.addContentTypeParser('*', ...)` so every content type is supported, and call `app.removeAllContentTypeParsers()` so every body is parsed by oRPC instead of Fastify: ```ts // Optional, let oRPC parse all content types app.removeAllContentTypeParsers() // Optional, support all content types app.addContentTypeParser('*', (request, payload, done) => { done(null, undefined) }) ``` ::: :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Event Stream Options You can configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `sendStandardResponse.eventStream` options when creating the handler. ```ts const handler = new OpenAPIHandler(router, { sendStandardResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` --- # Fetch API Adapter Source: https://orpc.dev/docs/adapters/fetch-api ## Server Usage ```ts RPC import { RPCHandler } from '@orpc/server/fetch' import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export async function fetch(request: Request): Promise { const { matched, response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return response } return new Response('Not found', { status: 404 }) } ``` ```ts OpenAPI import { OpenAPIHandler } from '@orpc/openapi/fetch' import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) export async function fetch(request: Request): Promise { const { matched, response } = await handler.handle(request, { prefix: '/api', context: {} // Provide initial context if needed }) if (matched) { return response } return new Response('Not found', { status: 404 }) } ``` :::info The actual usage of `fetch` depends on the runtime environment or library you use: ```ts Bun Bun.serve({ fetch, }) ``` ```ts Deno Deno.serve(fetch) ``` ::: :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Client Usage ```ts RPC import { RPCLink } from '@orpc/client/fetch' import { onError } from '@orpc/client' const link = new RPCLink({ origin: 'https://api.example.com', // accepts async function, defaults to current origin url: '/rpc', // accepts async function headers: { authorization: 'bearer token' }, // accept async function interceptors: [ onError((error) => { console.error(error) }), ], fetch: (request, init) => { // <- override fetch if needed return globalThis.fetch(request, { ...init, credentials: 'include', // Include cookies on cross-origin requests }) }, }) ``` ```ts OpenAPI import { OpenAPILink } from '@orpc/openapi/fetch' import { onError } from '@orpc/client' const link = new OpenAPILink(contract, { origin: 'https://api.example.com', // accepts async function, defaults to current origin url: '/rpc', // accepts async function headers: { authorization: 'bearer token' }, // accept async function interceptors: [ onError((error) => { console.error(error) }), ], fetch: (request, init) => { // <- override fetch if needed return globalThis.fetch(request, { ...init, credentials: 'include', // Include cookies on cross-origin requests }) }, }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Event Stream Options You can configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `toFetchResponse.eventStream` options when creating the handler. ```ts const handler = new OpenAPIHandler(router, { toFetchResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` :::info You can also configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed from client to server using `toFetchRequest.eventStream` options when creating the link. ::: --- # H3 Adapter Source: https://orpc.dev/docs/adapters/h3 [H3](https://h3.dev/) is a universal, tiny, and fast web framework built on top of web standards, so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { H3, serve } from 'h3' const app = new H3() const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) app.use('/rpc/**', async (event) => { const { matched, response } = await handler.handle(event.req, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return response } }) serve(app, { port: 3000 }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: --- # Hono Adapter Source: https://orpc.dev/docs/adapters/hono [Hono](https://hono.dev/) is a high-performance web framework built on top of the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { Hono } from 'hono' const app = new Hono() const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) app.use('/rpc/*', async (c, next) => { const { matched, response } = await handler.handle(c.req.raw, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return c.newResponse(response.body, response) } await next() }) export default app ``` If Hono middleware reads the request body before the oRPC handler processes it, an error will occur. You can solve this by using a proxy to intercept the request body parsers with Hono parsers. ```ts const BODY_PARSER_METHODS = new Set(['arrayBuffer', 'blob', 'formData', 'json', 'text'] as const) type BodyParserMethod = typeof BODY_PARSER_METHODS extends Set ? T : never app.use('/rpc/*', async (c, next) => { const request = new Proxy(c.req.raw, { get(target, prop) { if (prop === 'bodyUsed') { return false // Hono can still provide the body from its parser cache } if (BODY_PARSER_METHODS.has(prop as BodyParserMethod)) { return () => c.req[prop as BodyParserMethod]() } return Reflect.get(target, prop, target) } }) const { matched, response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return c.newResponse(response.body, response) } await next() }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: --- # Message Port Adapter Source: https://orpc.dev/docs/adapters/message-port ## Basic Usage Message Ports work by establishing two endpoints that can communicate with each other: ```ts Bridge const channel = new MessageChannel() const serverPort = channel.port1 const clientPort = channel.port2 ``` ```ts Server import { RPCHandler } from '@orpc/server/message-port' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) handler.upgrade(serverPort, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial upgrade request. */ context: request => ({}), }) serverPort.start() ``` ```ts Client import { RPCLink } from '@orpc/client/message-port' import { onError } from '@orpc/client' const link = new RPCLink({ port: clientPort, interceptors: [ onError((error) => { console.error(error) }), ], /** * Optional headers to attach to each per-call request. * These can be accessed in the server context or via the Request Headers Plugin. */ headers: () => ({}) }) clientPort.start() ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: ## Transfer By default, oRPC serializes request/response messages to string/binary data before sending over message port. If needed, you can define the `transfer` option to utilize full power of [MessagePort: postMessage() method](https://developer.mozilla.org/en-US/docs/Web/API/MessagePort/postMessage), such as transferring ownership of objects to the other side or support unserializable objects like `OffscreenCanvas`. ```ts handler const handler = new RPCHandler(router, { experimental_transfer: (message, port) => { const transfer = deepFindTransferableObjects(message) // implement your own logic return transfer.length ? transfer : null // only enable when needed } }) ``` ```ts link const link = new RPCLink({ experimental_transfer: (message) => { const transfer = deepFindTransferableObjects(message) // implement your own logic return transfer.length ? transfer : null // only enable when needed } }) ``` :::info When `transfer` returns an array, messages are sent using [the structured clone algorithm](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm), which doesn't support all data types. If you need to support additional data types, consider customizing your [RPC Serializer](/docs/rpc/serializer). ::: --- # Next.js Adapter Source: https://orpc.dev/docs/adapters/next [Next.js](https://nextjs.org/) is a leading React framework for server-rendered apps. oRPC works with both the [App Router](https://nextjs.org/docs/app/getting-started/installation) and [Pages Router](https://nextjs.org/docs/pages/getting-started/installation) through the [Fetch API Adapter](/docs/adapters/fetch-api) and [Node HTTP Adapter](/docs/adapters/node-http) respectively. :::info oRPC also supports [Next.js server functions](/docs/integrations/next) through the dedicated `@orpc/next` integration. ::: ## Server You set up an oRPC server inside Next.js using its [Route Handlers](https://nextjs.org/docs/app/building-your-application/routing/route-handlers). ```ts title="app/rpc/[[...rest]]/route.ts" import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) } export const HEAD = handleRequest export const GET = handleRequest export const POST = handleRequest export const PUT = handleRequest export const PATCH = handleRequest export const DELETE = handleRequest ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ```ts title="pages/api/rpc/[[...rest]].ts" import type { NextApiRequest, NextApiResponse } from 'next' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/node' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) export const config = { api: { bodyParser: false, }, } export default async (req: NextApiRequest, res: NextApiResponse) => { const { matched } = await handler.handle(req, res, { prefix: '/api/rpc', context: {} // Provide initial context if needed }) if (matched) { return } res.statusCode = 404 res.end('Not found') } ``` :::warning Next.js [body parser](https://nextjs.org/docs/pages/building-your-application/routing/api-routes#custom-config) may handle common request body types, and oRPC will use the parsed body if available. However, it doesn't support features like [Bracket Notation](/docs/openapi/bracket-notation), and in case you upload a file with `application/json`, it may be parsed as plain JSON instead of a `File`. To avoid these issues, disable the body parser with `config.api.bodyParser = false` as shown above. ::: ## Client By leveraging `headers` from `next/headers`, you can configure the link to work seamlessly in both browser and server environments: ```ts title="lib/orpc.ts" import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ url: '/rpc', origin: typeof window === 'undefined' ? 'http://localhost:3000' : undefined, // defaults to the current origin in the browser headers: async () => { if (typeof window !== 'undefined') { return {} } const { headers } = await import('next/headers') return await headers() }, }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Optimize SSR To reduce HTTP requests and improve latency during SSR, you can use a [server-side client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimizing SSR](/docs/recipes/optimizing-ssr) for more details. ```ts title="lib/orpc.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' if (import.meta.env.SSR) { await import('./orpc.server') } declare global { var $client: RouterClient | undefined } const link = new RPCLink({ url: '/rpc', origin: () => { if (typeof window === 'undefined') { throw new Error('This link is not allowed on the server side.') } return window.location.origin }, }) /** * Fall back to a browser client when no SSR client is registered. */ export const client: RouterClient = globalThis.$client ?? createORPCClient(link) ``` ```ts title="lib/orpc.server.ts" import { createRouterClient } from '@orpc/server' import { headers } from 'next/headers' globalThis.$client = createRouterClient(router, { /** * Provide initial context if needed. * * Because this client instance is shared across all requests, * only include context that's safe to reuse globally. * For per-request context, use middleware context or pass a function as the initial context. */ context: async () => ({ headers: await headers(), // provide headers if initial context required }), }) ``` :::warning `import.meta.env.SSR` requires [Turbopack](https://nextjs.org/docs/app/api-reference/turbopack), the default bundler since Next.js 16. On webpack builds, guard with `typeof window === 'undefined'` instead, which Next.js also replaces at build time. Either way, do not add `import 'server-only'` to `orpc.server.ts`: it would fail the build. ::: --- # Node HTTP Adapter Source: https://orpc.dev/docs/adapters/node-http ## Server Usage ```ts RPC import { createServer } from 'node:http' // or 'node:https' or 'node:http2' import { RPCHandler } from '@orpc/server/node' import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (matched) { return } res.statusCode = 404 res.end('Not found') }) server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) ``` ```ts OpenAPI import { createServer } from 'node:http' // or 'node:https' or 'node:http2' import { OpenAPIHandler } from '@orpc/openapi/node' import { CORSHandlerPlugin } from '@orpc/server/plugins' import { onError } from '@orpc/server' const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], interceptors: [ onError((error) => { console.error(error) }), ], }) const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/api', context: {} // Provide initial context if needed }) if (matched) { return } res.statusCode = 404 res.end('Not found') }) server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) ``` :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Event Stream Options You can configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `sendStandardResponse.eventStream` options when creating the handler. ```ts const handler = new OpenAPIHandler(router, { sendStandardResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` --- # Nuxt Adapter Source: https://orpc.dev/docs/adapters/nuxt [Nuxt](https://nuxt.com/) is a popular Vue.js framework for building server-side applications. Its server engine follows web standards, so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Server You set up an oRPC server inside Nuxt using its [Server Routes](https://nuxt.com/docs/guide/directory-structure/server#server-routes). ```ts title="server/routes/rpc/[...].ts" import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) export default defineEventHandler(async (event) => { const request = toWebRequest(event) const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) if (response) { return response } setResponseStatus(event, 404, 'Not Found') return 'Not found' }) ``` ```ts title="server/routes/rpc/index.ts" export { default } from './[...]' ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ## Client To make the oRPC client compatible with SSR, set it up inside a [Nuxt Plugin](https://nuxt.com/docs/guide/directory-structure/plugins). ```ts title="app/plugins/orpc.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' export default defineNuxtPlugin(() => { const event = useRequestEvent() const requestURL = useRequestURL() const link = new RPCLink({ url: '/rpc', origin: typeof window === 'undefined' ? requestURL.origin : undefined, // defaults to the current origin in the browser headers: () => event?.headers ?? {}, }) const client: RouterClient = createORPCClient(link) return { provide: { client, }, } }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Optimize SSR To reduce HTTP requests and improve latency during SSR, you can use a [server-side client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimizing SSR](/docs/recipes/optimizing-ssr) for more details. ```ts title="app/plugins/orpc.client.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' export default defineNuxtPlugin(() => { const link = new RPCLink({ url: '/rpc', }) const client: RouterClient = createORPCClient(link) return { provide: { client, }, } }) ``` ```ts title="app/plugins/orpc.server.ts" import { createRouterClient } from '@orpc/server' export default defineNuxtPlugin(() => { const event = useRequestEvent() const client = createRouterClient(router, { context: { headers: event?.headers, // provide headers if initial context required }, }) return { provide: { client, }, } }) ``` --- # React Router Adapter Source: https://orpc.dev/docs/adapters/react-router [React Router](https://reactrouter.com/) is a multi-strategy router for React, and the successor to [Remix](https://remix.run/). In framework mode, its resource routes follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Basic ```ts title="app/routes/rpc.ts" import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) } export async function loader({ request }: LoaderFunctionArgs) { return handleRequest(request) } export async function action({ request }: ActionFunctionArgs) { return handleRequest(request) } ``` ```ts title="app/routes.ts" import type { RouteConfig } from '@react-router/dev/routes' import { index, route } from '@react-router/dev/routes' export default [ index('routes/home.tsx'), route('rpc/*', 'routes/rpc.ts'), ] satisfies RouteConfig ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: :::info In a Remix v2 project, the same `loader` and `action` exports work in a splat route (`app/routes/rpc.$.ts`) with the types imported from `@remix-run/node`. ::: --- # SolidStart Adapter Source: https://orpc.dev/docs/adapters/solid-start [SolidStart](https://start.solidjs.com/) is a full stack JavaScript framework for building web applications with SolidJS. Its API routes follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Server ```ts title="src/routes/rpc/[...rest].ts" import type { APIEvent } from '@solidjs/start/server' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) async function handle({ request }: APIEvent) { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) } export const HEAD = handle export const GET = handle export const POST = handle export const PUT = handle export const PATCH = handle export const DELETE = handle ``` ```ts title="src/routes/rpc/index.ts" export { DELETE, GET, HEAD, PATCH, POST, PUT } from './[...rest]' ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ## Client On the client, use `getRequestEvent` to resolve the request origin and forward headers during SSR. This enables usage in both server and browser environments. ```ts import { RPCLink } from '@orpc/client/fetch' import { getRequestEvent } from 'solid-js/web' const link = new RPCLink({ url: '/rpc', // Resolve the origin from the incoming request during SSR; defaults to the current origin in the browser. origin: () => { const event = getRequestEvent() return event ? new URL(event.request.url).origin : undefined }, headers: () => getRequestEvent()?.request.headers ?? {}, }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Optimize SSR To reduce HTTP requests and improve latency during SSR, you can use a [server-side client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimizing SSR](/docs/recipes/optimizing-ssr) for more details. ```ts title="src/lib/orpc.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' if (import.meta.env.SSR) { await import('./orpc.server') } declare global { var $client: RouterClient | undefined } const link = new RPCLink({ url: '/rpc', origin: () => { if (typeof window === 'undefined') { throw new Error('This link is not allowed on the server side.') } return window.location.origin }, }) /** * Fall back to a browser client when no SSR client is registered. */ export const client: RouterClient = globalThis.$client ?? createORPCClient(link) ``` ```ts title="src/lib/orpc.server.ts" import { createRouterClient } from '@orpc/server' import { getRequestEvent } from 'solid-js/web' if (typeof window !== 'undefined') { throw new Error('This file should not be imported in the browser') } globalThis.$client = createRouterClient(router, { /** * Provide initial context if needed. * * Because this client instance is shared across all requests, * only include context that's safe to reuse globally. * For per-request context, use middleware context or pass a function as the initial context. */ context: async () => { const headers = getRequestEvent()?.request.headers return { headers, // provide headers if initial context required } }, }) ``` :::warning Guard the import with `import.meta.env.SSR`, which Vite replaces at build time, so the server module is stripped from client bundles. A `typeof window` check is not enough: the bundler would still emit your router as a publicly downloadable client chunk. ::: --- # SvelteKit Adapter Source: https://orpc.dev/docs/adapters/svelte-kit [SvelteKit](https://svelte.dev/docs/kit/introduction) is a framework for rapidly developing robust, performant web applications using Svelte. Its endpoints follow the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Server ```ts title="src/routes/rpc/[...rest]/+server.ts" import type { RequestHandler } from './$types' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) const handle: RequestHandler = async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) } export const GET = handle export const POST = handle export const PUT = handle export const PATCH = handle export const DELETE = handle ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ## Client During SSR, use [SvelteKit's `fetch`](https://svelte.dev/docs/kit/load#Making-fetch-requests), which forwards the `cookie` and `authorization` headers and calls the endpoint directly without an HTTP round trip. ```ts title="src/lib/orpc.ts" import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ url: '/rpc', fetch: async (url, init) => { if (import.meta.env.SSR) { const { getRequestEvent } = await import('$app/server') return getRequestEvent().fetch(url, init) } return fetch(url, init) }, }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Optimize SSR SvelteKit's `fetch` already skips the HTTP round trip, but requests are still serialized and deserialized. To remove that overhead as well, use a [server-side client](/docs/client/server-side) during SSR as described in [Optimizing SSR](/docs/recipes/optimizing-ssr#using-server-side-client-directly). --- # TanStack Start Adapter Source: https://orpc.dev/docs/adapters/tanstack-start [TanStack Start](https://tanstack.com/start) is a full-stack React framework built on [Vite](https://vite.dev/) and the [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API), so oRPC integrates through the [Fetch API Adapter](/docs/adapters/fetch-api). ## Server You set up an oRPC server inside TanStack Start using its [Server Routes](https://tanstack.com/start/latest/docs/framework/react/guide/server-routes). ```ts title="src/routes/api/rpc.$.ts" import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' import { createFileRoute } from '@tanstack/react-router' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) export const Route = createFileRoute('/api/rpc/$')({ server: { handlers: { ANY: async ({ request }) => { const { response } = await handler.handle(request, { prefix: '/api/rpc', context: {} // Provide initial context if needed }) return response ?? new Response('Not found', { status: 404 }) }, }, }, }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or another custom handler. ::: ## Client Use `createIsomorphicFn` to configure the link with environment-specific settings for both browser and SSR environments: ```ts import { RPCLink } from '@orpc/client/fetch' import { createIsomorphicFn } from '@tanstack/react-start' import { getRequest, getRequestHeaders } from '@tanstack/react-start/server' const getClientLink = createIsomorphicFn() .client(() => new RPCLink({ url: '/api/rpc', })) .server(() => new RPCLink({ url: '/api/rpc', origin: () => new URL(getRequest().url).origin, // resolve from the incoming request headers: () => getRequestHeaders(), })) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients) and [OpenAPI Link](/docs/openapi/link#typesafe-clients). ::: ## Optimize SSR To reduce HTTP requests and improve latency during SSR, you can use a [server-side client](/docs/client/server-side) during SSR. Below is a quick setup, see [Optimizing SSR](/docs/recipes/optimizing-ssr) for more details. ```ts title="src/lib/orpc.ts" import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { createRouterClient } from '@orpc/server' import { createIsomorphicFn } from '@tanstack/react-start' import { getRequestHeaders } from '@tanstack/react-start/server' const getORPCClient = createIsomorphicFn() .server(() => createRouterClient(router, { /** * Provide initial context if needed. * * Because this client instance is shared across all requests, * only include context that's safe to reuse globally. * For per-request context, use middleware context or pass a function as the initial context. */ context: async () => ({ headers: getRequestHeaders(), // provide headers if initial context required }), })) .client((): RouterClient => { const link = new RPCLink({ url: '/api/rpc', }) return createORPCClient(link) }) export const client: RouterClient = getORPCClient() ``` --- # Web Workers Adapter Source: https://orpc.dev/docs/adapters/web-workers [Web Workers](https://developer.mozilla.org/en-US/docs/Web/API/Worker) allow JavaScript code to run in background threads, separate from the main thread of a web page. This prevents blocking the UI while performing computationally intensive tasks. Web Workers are also supported in modern runtimes like [Bun](https://bun.com/docs/api/workers), [Deno](https://docs.deno.com/examples/web_workers/), etc. With oRPC, you can establish typesafe communication channels between your main thread and Web Workers through the [Message Port Adapter](/docs/adapters/message-port). ## Web Worker Configure your Web Worker to handle oRPC requests by upgrading it with a message port handler: ```ts import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) handler.upgrade(self, { context: {} // Provide initial context if needed }) ``` ## Main Thread Create a link to communicate with your Web Worker: ```ts import { RPCLink } from '@orpc/client/message-port' export const link = new RPCLink({ port: new Worker('some-worker.js') }) ``` You can leverage the [Vite Web Workers feature](https://vite.dev/guide/features.html#web-workers) for streamlined development: ```ts import { RPCLink } from '@orpc/client/message-port' import SomeWorker from './some-worker.ts?worker' export const link = new RPCLink({ port: new SomeWorker() }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: --- # WebSocket Adapters Source: https://orpc.dev/docs/adapters/websocket ## Server Adapters | Adapter | Target | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket), [ws](https://github.com/websockets/ws), [Bun's WebSocket](https://bun.com/docs/runtime/http/websockets), [Deno's WebSocket](https://docs.deno.com/examples/http_server_websocket/), [Cloudflare Hibernation WebSocket](https://developers.cloudflare.com/durable-objects/best-practices/websockets/), [uWebSockets](https://github.com/uNetworking/uWebSockets.js/) | | `crossws` | [crossws](https://github.com/h3js/crossws) | ```ts ws import { WebSocketServer } from 'ws' import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) const wss = new WebSocketServer({ port: 8080 }) wss.on('connection', (ws) => { handler.upgrade(ws, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) }) ``` ```ts crossws import { createServer } from 'node:http' import { experimental_RPCHandler as RPCHandler } from '@orpc/server/crossws' import { onError } from '@orpc/server' // any crossws adapter is supported import crossws from 'crossws/adapters/node' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) const ws = crossws({ hooks: { message: async (peer, message) => { await handler.message(peer, message, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) }, close: async (peer) => { await handler.close(peer) }, }, }) const server = createServer((req, res) => { res.end(`Hello World`) }).listen(3000) server.on('upgrade', (req, socket, head) => { if (req.headers.upgrade === 'websocket') { ws.handleUpgrade(req, socket, head) } }) ``` ```ts Bun import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) Bun.serve({ fetch(req, server) { if (server.upgrade(req)) { return } return new Response('Upgrade failed', { status: 500 }) }, websocket: { async message(ws, message) { await handler.message(ws, message, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) }, async close(ws) { await handler.close(ws) }, } }) ``` ```ts Deno import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) Deno.serve((req) => { if (req.headers.get('upgrade') !== 'websocket') { return new Response(null, { status: 501 }) } const { socket, response } = Deno.upgradeWebSocket(req) handler.upgrade(socket, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) return response }) ``` ```ts Cloudflare import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) export class ChatRoom extends DurableObject { async fetch(): Promise { const { '0': client, '1': server } = new WebSocketPair() this.ctx.acceptWebSocket(server) return new Response(null, { status: 101, webSocket: client, }) } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { await handler.message(ws, message, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) } async webSocketClose(ws: WebSocket): Promise { await handler.close(ws) } } ``` ```ts uWebSockets import { App } from 'uWebSockets.js' import { RPCHandler } from '@orpc/server/websocket' import { onError } from '@orpc/server' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) const app = App() .ws('/*', { async message(ws, message, isBinary) { await handler.message(ws, message, { /** * Provide initial context if needed. The context can be an async function * that receives the per-call request as its first argument, and is **not** * related to the initial WebSocket upgrade request. */ context: request => ({}), }) }, async close(ws, code, message) { await handler.close(ws) }, }) .listen(3000, (token) => { if (token) { console.log('Listening to port 3000') } }) ``` ## Client Adapters | Adapter | Target | | ----------- | ------------------------------------------------------------------------------- | | `websocket` | [MDN WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) | ```ts import { RPCLink } from '@orpc/client/websocket' const link = new RPCLink({ connect: info => new WebSocket('ws://localhost:3000'), /** * Whether to connect immediately on initialization, instead of waiting * for the first call. Reduces latency for the first request. * * @default false */ connectOnInit: true, /** * Optional headers to attach to each per-call request. * These can be accessed in the server context or via the Request Headers Plugin. */ headers: () => ({}) }) ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: ### Auto Reconnect The client adapter has built-in support for reconnecting when the connection is lost. You can configure reconnect behavior with the `reconnect` option when creating the link. ```ts const link = new RPCLink({ reconnect: { /** * Whether to automatically reconnect when the connection is lost. * * @default false */ enabled: true, /** * Delay before a (re)connect attempt, in milliseconds. * * @default info => info.attempt === 1 ? 0 : 2_000 */ delay: info => info.attempt === 1 ? 0 : 2_000, /** * Maximum number of consecutive failed attempts before giving up. * When exceeded, `getConnectedPeer` throws instead of retrying. * Should greater than 1 * * @default Infinity */ maxAttempt: Infinity, onClose: { /** * Whether to proactively reconnect right after the socket closes, * rather than waiting for the next call to trigger reconnection. * Reduces latency for the next request. * * @default false */ enabled: false, /** * Delay before reconnecting after the socket closes, in milliseconds. * * @default 0 */ delay: 0 } } }) ``` --- # Worker Threads Adapter Source: https://orpc.dev/docs/adapters/worker-threads Use [Node.js Worker Threads](https://nodejs.org/api/worker_threads.html) with oRPC for typesafe inter-thread communication via the [Message Port Adapter](/docs/adapters/message-port). ## Worker Thread Listen for a `MessagePort` sent from the main thread and upgrade it: ```ts import { parentPort } from 'node:worker_threads' import { onError } from '@orpc/server' import { RPCHandler } from '@orpc/server/message-port' const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], }) parentPort?.on('message', (message) => { if (message instanceof MessagePort) { handler.upgrade(message, { context: {} // Provide initial context if needed }) message.start() } }) ``` ## Main Thread Create a `MessageChannel`, send one port to the worker thread, and use the other to initialize the client link: ```ts import { MessageChannel, Worker } from 'node:worker_threads' import { RPCLink } from '@orpc/client/message-port' const { port1: clientPort, port2: serverPort } = new MessageChannel() const worker = new Worker(new URL('./some-worker.js', import.meta.url)) worker.postMessage(serverPort, [serverPort]) const link = new RPCLink({ port: clientPort }) clientPort.start() ``` :::info The examples above only show how to configure the link. For examples of creating a typesafe client, see [RPC Link](/docs/rpc/link#typesafe-clients). ::: --- # API Reference Source: https://orpc.dev/docs/api-reference Each package name links to its reference on [npmx](https://npmx.dev): every export with its signature and description, generated from the TypeScript declarations the package publishes. The guides beside it show those APIs in use. :::tip For questions the reference does not answer, [oRPC on DeepWiki](https://deepwiki.com/middleapi/orpc) maps out how the codebase fits together and lets you ask an AI assistant about any part of it. ::: ## Core | Package | Purpose | Related Guides | | ------- | ------- | -------------- | | [@orpc/contract](https://npmx.dev/package-docs/@orpc/contract) | Define API contract as the single source of truth. | [Procedure Contract](/docs/contract/procedure), [Contract Router](/docs/contract/router), [Implementation](/docs/contract/implementation), [Generate from OpenAPI](/docs/contract/generate-from-openapi) | | [@orpc/server](https://npmx.dev/package-docs/@orpc/server) | Build APIs or implement contracts. | [Procedure](/docs/procedure), [Router](/docs/router), [Middleware](/docs/middleware), [Context](/docs/context), [RPC Handler](/docs/rpc/handler) | | [@orpc/client](https://npmx.dev/package-docs/@orpc/client) | Consume APIs with end-to-end type safety. | [Client-Side Clients](/docs/client/client-side), [Server-Side Clients](/docs/client/server-side), [RPC Link](/docs/rpc/link), [Error Handling](/docs/client/error-handling) | | [@orpc/openapi](https://npmx.dev/package-docs/@orpc/openapi) | Add OpenAPI compatibility to APIs. | [OpenAPI Handler](/docs/openapi/handler), [OpenAPI Link](/docs/openapi/link), [Specification](/docs/openapi/specification), [Scalar](/docs/openapi/scalar) | ## Schema Validation | Package | Purpose | Related Guides | | ------- | ------- | -------------- | | [@orpc/zod](https://npmx.dev/package-docs/@orpc/zod) | Integrate with Zod. | [Zod](/docs/integrations/zod) | | [@orpc/valibot](https://npmx.dev/package-docs/@orpc/valibot) | Integrate with Valibot. | [Valibot](/docs/integrations/valibot) | | [@orpc/arktype](https://npmx.dev/package-docs/@orpc/arktype) | Integrate with ArkType. | [ArkType](/docs/integrations/arktype) | ## Built-in Features | Package | Purpose | Related Guides | | ------- | ------- | -------------- | | [@orpc/publisher](https://npmx.dev/package-docs/@orpc/publisher) | Pub/Sub with memory, Redis, and Upstash adapters. | [Publisher](/docs/helpers/publisher) | | [@orpc/ratelimit](https://npmx.dev/package-docs/@orpc/ratelimit) | Rate limiting with memory, Redis, and Upstash adapters. | [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/hibernation](https://npmx.dev/package-docs/@orpc/hibernation) | Leverage Hibernation APIs like Cloudflare's WebSocket Hibernation. | [Hibernation](/docs/integrations/hibernation) | | [@orpc/json-schema](https://npmx.dev/package-docs/@orpc/json-schema) | Smart coercion for OpenAPI requests. | [Smart Coercion](/docs/plugins/smart-coercion) | ## Framework and Ecosystem Integrations | Package | Purpose | Related Guides | | ------- | ------- | -------------- | | [@orpc/next](https://npmx.dev/package-docs/@orpc/next) | Integrate with Next.js Server Functions. | [Next.js](/docs/integrations/next) | | [@orpc/ai-sdk](https://npmx.dev/package-docs/@orpc/ai-sdk) | Turn contracts and procedures into AI SDK tools. | [AI SDK](/docs/integrations/ai-sdk) | | [@orpc/tanstack-query](https://npmx.dev/package-docs/@orpc/tanstack-query) | Integrate with TanStack Query. | [TanStack Query](/docs/integrations/tanstack-query) | | [@orpc/pinia-colada](https://npmx.dev/package-docs/@orpc/pinia-colada) | Integrate with Pinia Colada. | [Pinia Colada](/docs/integrations/pinia-colada) | | [@orpc/swr](https://npmx.dev/package-docs/@orpc/swr) | Integrate with SWR. | [SWR](/docs/integrations/swr) | | [@orpc/experimental-effect](https://npmx.dev/package-docs/@orpc/experimental-effect) | Integrate with Effect. | [Effect](/docs/integrations/effect) | | [@orpc/experimental-msw](https://npmx.dev/package-docs/@orpc/experimental-msw) | Mock procedures at the network level with typed MSW request handlers. | [MSW](/docs/integrations/msw) | | [@orpc/nest](https://npmx.dev/package-docs/@orpc/nest) | Implement your contract with NestJS. | [NestJS](/docs/integrations/nest) | | [@orpc/node](https://npmx.dev/package-docs/@orpc/node) | Node.js plugins for static file serving and large uploads. | [Static File](/docs/plugins/static-file), [Tmp File Upload](/docs/plugins/tmp-file-upload), [Batch Response Compression](/docs/plugins/batch-response-compression) | | [@orpc/bun](https://npmx.dev/package-docs/@orpc/bun) | Bun Redis adapters for Publisher and Rate Limit. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit) | | [@orpc/cloudflare](https://npmx.dev/package-docs/@orpc/cloudflare) | Adapters for Cloudflare Workers. | [Publisher](/docs/helpers/publisher), [Rate Limit](/docs/helpers/ratelimit), [Traces](/docs/integrations/cloudflare-traces) | | [@orpc/trpc](https://npmx.dev/package-docs/@orpc/trpc) | Reuse existing tRPC routers within oRPC. | [tRPC](/docs/integrations/trpc) | ## Observability | Package | Purpose | Related Guides | | ------- | ------- | -------------- | | [@orpc/opentelemetry](https://npmx.dev/package-docs/@orpc/opentelemetry) | Distributed tracing with OpenTelemetry. | [OpenTelemetry](/docs/integrations/opentelemetry) | | [@orpc/pino](https://npmx.dev/package-docs/@orpc/pino) | Logging with Pino. | [Pino](/docs/integrations/pino) | | [@orpc/evlog](https://npmx.dev/package-docs/@orpc/evlog) | Logging with Evlog. | [Evlog](/docs/integrations/evlog) | --- # AsyncIteratorObject (SSE) Source: https://orpc.dev/docs/async-iterator-object ## Overview An `AsyncIteratorObject` is implemented as an [asynchronous generator function](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/async_function*) (or a compatible implementation). In the example below, the handler emits a new event every second: ```ts const example = os .handler(async function* ({ input, signal, lastEventId }) { while (true) { signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } }) ``` :::info Learn how to consume an `AsyncIteratorObject` from the client in the [client guide](/docs/client/async-iterator-object). ::: ## Validating Events Use the built‑in `asyncIteratorObject` schema that works with any [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library to validate events. ```ts import { asyncIteratorObject } from '@orpc/server' const example = os .output(asyncIteratorObject(z.object({ message: z.string() }))) .handler(async function* ({ input, signal, lastEventId }) { while (true) { signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } }) ``` ## Last Event ID & Event Metadata Using the `withEventMeta` helper, you can attach [additional event metadata](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) (such as an event ID or retry interval) to each event. When the client reconnects properly, the last received event ID is sent back to the server in `lastEventId`, allowing the stream to resume from where it left off. :::info When used with the [Retry Plugin](/docs/plugins/retry) or [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), reconnection with the last event ID is handled automatically. ::: ```ts import { withEventMeta } from '@orpc/server' const example = os .handler(async function* ({ input, signal, lastEventId }) { if (lastEventId) { // Resume streaming from lastEventId } else { while (true) { signal?.throwIfAborted() yield withEventMeta( { message: 'Hello, world!' }, { id: 'some-id', retry: 10_000 } ) await new Promise(resolve => setTimeout(resolve, 1000)) } } }) ``` ## Stop AsyncIteratorObject To end the stream, use either a `return` or `throw` statement. oRPC marks the stream as completed when the handler returns. :::warning This behavior is specific to oRPC. Standard [SSE](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events) clients, such as [EventSource](https://developer.mozilla.org/en-US/docs/Web/API/EventSource), do not recognize this completion signal and will automatically attempt to reconnect. For details, see the [Standard Server documentation](https://github.com/middleapi/standard-server#event-stream-body). ::: ```ts const example = os .handler(async function* ({ input, signal, lastEventId }) { while (true) { signal?.throwIfAborted() if (done) { return } yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } }) ``` ## Signal and Side-Effects When the client closes the connection or an unexpected error occurs, oRPC aborts the provided `signal`. Use it to exit loops and avoid resource leaks. Put cleanup logic in a `finally` block so it runs whether the stream ends normally, errors, or is cancelled. ```ts const example = os .handler(async function* ({ input, signal, lastEventId }) { try { while (true) { signal?.throwIfAborted() yield { message: 'Hello, world!' } await new Promise(resolve => setTimeout(resolve, 1000)) } } finally { console.log('Cleanup logic here') } }) ``` ## Publisher Helper You can combine the [AsyncIteratorObject](/docs/async-iterator-object) with the [Publisher Helper](/docs/helpers/publisher) to build real-time features like chat, notifications, or live updates with resume support. ```ts const publisher = new MemoryPublisher<{ 'something-updated': { id: string } }>() const live = os .handler(async function* ({ input, signal, lastEventId }) { const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { // Handle payload here or yield directly to client yield payload } }) const publish = os .input(z.object({ id: z.string() })) .handler(async ({ input }) => { await publisher.publish('something-updated', { id: input.id }) }) ``` --- # Binary Data Source: https://orpc.dev/docs/binary-data [File](https://developer.mozilla.org/en-US/docs/Web/API/File), [Blob](https://developer.mozilla.org/en-US/docs/Web/API/Blob), and [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) are supported by the [RPC Serializer](/docs/rpc/serializer) and [OpenAPI Serializer](/docs/openapi/serializer). Use them to handle binary data in your procedures. :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## `File` and `Blob` Procedures can accept `File` and `Blob` as input and return them directly or inside nested structures. :::warning `File` and `Blob` are buffered in memory by default. For large files on Node, use the [Tmp File Upload Plugin](/docs/plugins/tmp-file-upload) to stream uploads into temporary files instead. ::: ```ts twoslash import { os } from '@orpc/server' import * as z from 'zod' // ---cut--- const example = os .input(z.file()) .output(z.object({ anyFieldName: z.instanceof(File) })) .handler(async ({ input }) => { const file = input console.log(file.name) return { anyFieldName: new File(['Hello World'], 'hello.txt', { type: 'text/plain' }), } }) ``` ## `ReadableStream` Procedures can return `ReadableStream` to stream binary responses. The example below uses the [Response Headers Plugin](/docs/plugins/response-headers) to set the appropriate `Content-Type` header. ```ts twoslash import { os } from '@orpc/server' import { ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' import * as z from 'zod' interface ServerContext extends ResponseHeadersHandlerPluginContext {} const base = os.$context() // ---cut--- const example = base .output(z.instanceof(ReadableStream)) .handler(async ({ context }) => { context.resHeaders?.set('Content-Type', 'text/plain') const stream = new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('Hello World')) controller.close() } }) return stream }) ``` --- # AsyncIteratorObject in Client Source: https://orpc.dev/docs/client/async-iterator-object ## Basic Usage Await a call to a procedure that returns an [AsyncIteratorObject](/docs/async-iterator-object), then iterate over the events as they arrive, just like an [AsyncGenerator](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncGenerator). ```ts twoslash import { asyncIteratorObject, oc, RouterContractClient } from '@orpc/contract' import { z } from 'zod' const contract = { streaming: oc.output(asyncIteratorObject(z.object({ message: z.string() }))) } declare const client: RouterContractClient // ---cut--- const iterator = await client.streaming() for await (const event of iterator) { console.log(event.message) } ``` ## Stopping the Stream Use an `AbortSignal` or call `.return` to stop the iterator. ```ts const controller = new AbortController() const iterator = await client.streaming(undefined, { signal: controller.signal }) // Stop the stream after 1 second setTimeout(async () => { controller.abort() // Or call `await iterator.return()` if you already have the iterator instance. }, 1000) for await (const event of iterator) { console.log(event.message) } ``` ## Error Handling :::info Unlike traditional SSE, AsyncIteratorObjects do not retry automatically after an error. To add retries, use the [Retry Plugin](/docs/plugins/retry#event-source-simulation). ::: ```ts const iterator = await client.streaming() try { for await (const event of iterator) { console.log(event.message) } } catch (error) { if (error instanceof ORPCError) { // Handle the error here } } ``` ## Event Metadata Use `getEventMeta` to read [event metadata](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events#event_stream_format) for each item, such as the event ID and retry interval. ```ts import { getEventMeta } from '@orpc/client' const iterator = await client.streaming() for await (const event of iterator) { const meta = getEventMeta(event) console.log(event.message, meta?.id, meta?.retry) } ``` ## Using `consumeAsyncIterator` Use `consumeAsyncIterator` to consume an `AsyncIterator` with lifecycle callbacks. It accepts either an iterator or a promise that resolves to one. ```ts import { consumeAsyncIterator } from '@orpc/client' const cancel = consumeAsyncIterator(client.streaming(), { onEvent: (event) => { console.log(event.message) }, onError: (error) => { console.error(error) }, onSuccess: (value) => { console.log(value) }, onFinish: (state) => { console.log(state) }, }) setTimeout(async () => { // Stop the stream after 1 second await cancel() }, 1000) ``` --- # Client-Side Clients Source: https://orpc.dev/docs/client/client-side ## Installation ```package-install npm install @orpc/client@beta ``` ## Creating a Client To create a client, first set up a link that defines how the client communicates with the server. This can be an [RPC Link](/docs/rpc/link), an [OpenAPI Link](/docs/openapi/link), or any custom link. Then create a client for your [router](/docs/router) or [contract](/docs/contract/router) using `createORPCClient`. ```ts import { createORPCClient } from '@orpc/client' import { RouterContractClient } from '@orpc/contract' import { RouterClient } from '@orpc/server' // if you are following contract-first approach const contractClient: RouterContractClient = createORPCClient(link) // if you are following normal approach const normalClient: RouterClient = createORPCClient(link) ``` :::tip You can export `RouterClient` or `RouterContractClient` from the server to avoid importing the contract or router in the client. ::: ## Calling Procedures Once your client is set up, you can call your [procedures](/docs/procedure) as if they were local functions. ```ts twoslash import * as z from 'zod' import { os, RouterClient } from '@orpc/server' const router = { ping: os.handler(() => 'pong'), pong: os.handler(() => 'ping'), } declare const client: RouterClient // ---cut--- const pong = await client.ping() client.ping // ^| // // ``` ## Client Context Client context lets you pass values with each call, such as auth tokens or cache hints. ```ts interface ClientContext { token?: string } // if you are following contract-first approach const client: RouterContractClient = createORPCClient(link) // if you are following normal approach const client: RouterClient = createORPCClient(link) const output = await client.someProcedure(input, { context: { token: 'abc123', }, }) ``` ## Interceptors Interceptors let you wrap client calls. They are similar to interceptors in links, but are more typesafe because the exact input, output, and error types of each client are known. You can provide per-client interceptors with `scoped`. ```ts import { isDefinedError, safe } from '@orpc/client' const client: RouterClient = createORPCClient(link, { interceptors: [ async ({ context, path, next }) => { const [error, data] = await safe(next()) if (error) { if (isDefinedError(error)) { // handle typesafe errors } throw error } return data } ], scoped: { planet: { find: { interceptors: [ // <- these interceptors only apply to client.planet.find async ({ context, path, next }) => { return next() } ] } } } }) ``` :::info You can use [`safe` and `isDefinedError`](/docs/client/error-handling#using-safe-and-isdefinederror) together for typesafe error handling in interceptors. ::: ## Merging Clients In oRPC, a client is just an object-like structure. To merge multiple clients, assign each client to a property on a new object: ```ts const clientA: RouterClient = createORPCClient(linkA) const clientB: RouterClient = createORPCClient(linkB) const clientC: RouterClient = createORPCClient(linkC) export const orpc = { a: clientA, b: clientB, c: clientC, } ``` ## Utilities :::info These utilities can also be used for [server-side clients](/docs/client/server-side) and are not specific to client-side clients. ::: ### Infer Client Inputs Infers input types for each procedure in a client. ```ts import type { InferClientInputs } from '@orpc/client' type Inputs = InferClientInputs type FindPlanetInput = Inputs['planet']['find'] ``` ### Infer Client Body Inputs Infers body input types for each procedure in a client. If an endpoint's input includes `{ body: ... }`, only the `body` portion is extracted. Otherwise, the entire input type is used. ```ts import type { InferClientBodyInputs } from '@orpc/client' type BodyInputs = InferClientBodyInputs type FindPlanetBodyInput = BodyInputs['planet']['find'] ``` ### Infer Client Outputs Infers output types for each procedure in a client. ```ts import type { InferClientOutputs } from '@orpc/client' type Outputs = InferClientOutputs type FindPlanetOutput = Outputs['planet']['find'] ``` ### Infer Client Body Outputs Infers body output types for each procedure in a client. If an endpoint's output includes `{ body: ... }`, only the `body` portion is extracted. Otherwise, the entire output type is used. ```ts import type { InferClientBodyOutputs } from '@orpc/client' type BodyOutputs = InferClientBodyOutputs type FindPlanetBodyOutput = BodyOutputs['planet']['find'] ``` ### Infer Client Errors Infers the errors each procedure in a client can throw when using [type-safe error handling](/docs/error-handling#typesafe-errors). ```ts import type { InferClientErrors } from '@orpc/client' type Errors = InferClientErrors type FindPlanetError = Errors['planet']['find'] ``` ### Infer Client Error Infers all possible errors the entire client can throw. This is useful with [type-safe error handling](/docs/error-handling#typesafe-errors). ```ts import type { InferClientError } from '@orpc/client' type ClientError = InferClientError ``` ### Infer Client Context Infers the [client context](#client-context) type from a client. ```ts import type { InferClientContext } from '@orpc/client' type Context = InferClientContext ``` --- # DynamicLink Source: https://orpc.dev/docs/client/dynamic-link ## Example ```ts twoslash import { os, RouterClient } from '@orpc/server' import { RPCLink } from '@orpc/client/fetch' const router = { ping: os.handler(() => 'pong'), pong: os.handler(() => 'ping'), } // ---cut--- import { createORPCClient, DynamicLink } from '@orpc/client' interface ClientContext { cache?: boolean } const cacheLink = new RPCLink({ origin: 'https://cache.example.com', }) const noCacheLink = new RPCLink({ origin: 'https://example.com', }) const link = new DynamicLink((options, path, input) => { if (options.context?.cache) { return cacheLink } return noCacheLink }) const client: RouterClient = createORPCClient(link) ``` :::info This example uses two [RPC Link](/docs/rpc/link) instances, but `DynamicLink` works with any other link. ::: --- # Client Error Handling Source: https://orpc.dev/docs/client/error-handling ## Using `try/catch` For most calls, use regular `try/catch`. ```ts try { const data = await client.doSomething({ id: '123' }) } catch (error) { // handle error } ``` ## Using `safe` and `isDefinedError` When working with [Typesafe Errors](/docs/error-handling#typesafe-errors), use `safe` to preserve error type inference. It behaves like `try/catch`, but returns the typesafe result instead of throwing. ```ts twoslash import { call, os } from '@orpc/server' import * as z from 'zod' // ---cut--- import { isDefinedError, safe } from '@orpc/client' const exampleProcedure = os .input(z.object({ id: z.string() })) .errors({ RATE_LIMIT_EXCEEDED: { data: z.object({ retryAfter: z.number() }) } }) .handler(async ({ input, errors }) => { throw errors.RATE_LIMIT_EXCEEDED({ data: { retryAfter: 1000 } }) }) // or { error, data, definedError } const [error, data, definedError] = await safe( call(exampleProcedure, { id: '123' }) ) if (isDefinedError(error)) { // or definedError // handle defined error // or definedError.data.retryAfter console.log(error.data.retryAfter) } else if (error) { // handle unknown error } else { // handle success console.log(data) } ``` :::info `safe` supports both tuple and object forms: - `[error, data, definedError]` - `{ error, data, definedError }` `definedError` is the same value as `error` when `isDefinedError(error)` returns `true`; otherwise it is `null`. ::: ## Safe Client If you use `safe` often, `createSafeClient` can reduce repetition by wrapping entire client calls with `safe`. ```ts import { createSafeClient } from '@orpc/client' const safeClient = createSafeClient(client) const [error, data] = await safeClient.doSomething({ id: '123' }) ``` --- # Server-Side Clients Source: https://orpc.dev/docs/client/server-side ## One-Off Calls Use `call` when you need to invoke a single procedure without creating a client instance. ```ts twoslash import * as z from 'zod' const exampleProcedure = os .input(z.string()) .handler(async ({ input }) => ({ id: input })) // ---cut--- import { call, os } from '@orpc/server' const result = await call(exampleProcedure, 'input', { context: {} // <- provide initial context if needed }) ``` ## Router Clients Use `createRouterClient` to create a client for your [router](/docs/router). This is useful when you want to call multiple procedures. ```ts twoslash import * as z from 'zod' import { os } from '@orpc/server' const router = { ping: os.handler(() => 'pong'), pong: os.handler(() => 'ping'), } // ---cut--- import { createRouterClient } from '@orpc/server' const client = createRouterClient(router, { context: {}, // <- provide initial context if needed, can be async function interceptors: [ async ({ next, path }) => { console.time(path.join('.')) try { return await next() } catch (err) { console.error(`${path.join('.')}:`, err) throw err } finally { console.timeEnd(path.join('.')) } } ] }) const result = await client.ping() ``` ### Client Context Client context is passed with each call. Use it to switch between contexts, such as different users or tenants, without creating multiple client instances. ```ts twoslash import * as z from 'zod' import { createRouterClient, os } from '@orpc/server' const router = { ping: os.handler(() => 'pong'), pong: os.handler(() => 'ping'), } // ---cut--- interface ClientContext { cache?: boolean } const client = createRouterClient(router, { context: ({ cache }: ClientContext) => { // [!code highlight] if (cache) { return {} // <- context when cache enabled } return {} } }) const result = await client.ping(undefined, { context: { cache: true } }) ``` ### Interceptors Interceptors let you observe or modify an entire call. Common use cases include logging, error handling, and metrics collection. ```ts const client = createRouterClient(router, { interceptors: [ async ({ next, path, context }) => { console.time(path.join('.')) try { const output = await next() return output } catch (err) { console.error(`${path.join('.')}:`, err) throw err } finally { console.timeEnd(path.join('.')) } } ] }) ``` ## `.callable` extension Import `@orpc/server/extensions/callable` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds a `.callable` method to the decorated procedure, allowing you to call it directly like a regular function while still using it as a regular procedure. ```ts usage const ping = base .input(z.object({ name: z.string(), })) .handler(async ({ input }) => `Hello ${input.name}!`) .callable({ context: async () => ({}), // <- provide initial context if needed, can be async function interceptors: [], // <- client interceptors }) const router = { ping, // <- still use it as a regular procedure } const message = await ping({ name: 'World' }) // <- or call it directly ``` ```ts setup import '@orpc/server/extensions/callable' import { os } from '@orpc/server' export const base = os ``` ## Lifecycle The diagram below shows how a call flows through a server-side client. By default, middlewares registered before `.input` run before input validation, and the rest run after it: ```mermaid sequenceDiagram actor Caller participant Validator as Input/Output Validator participant Handler Caller ->> Validator: input, signal, lastEventId, ... Note over Validator: interceptors Note over Validator: middlewares registered before .input Validator ->> Validator: validate input Validator -->> Caller: if invalid input Note over Validator: middlewares registered after .input Validator ->> Handler: validated input Handler ->> Handler: execute handler Handler ->> Validator: output or error Validator ->> Validator: validate output Validator ->> Caller: output or error ``` --- # Comparison Source: https://orpc.dev/docs/comparison oRPC, [tRPC](https://trpc.io/), and [Hono](https://hono.dev/) all deliver end-to-end typesafe APIs in TypeScript, but they take different approaches. tRPC focuses on RPC for full-stack TypeScript apps, Hono is a general-purpose web framework with an RPC feature, and oRPC combines typesafe RPC with first-class OpenAPI support. ## Features - ✅ First-class, built-in support - 🟡 Lacks features or requires third-party integrations - 🛑 Not supported or not documented ### Type Safety | Feature | oRPC | tRPC | Hono | | --- | --- | --- | --- | | End-to-end typesafe input/output | ✅ | ✅ | ✅ | | [End-to-end typesafe errors](/docs/client/error-handling) | ✅ | 🟡 | ✅ | | [End-to-end typesafe File/Blob](/docs/binary-data) | ✅ | 🟡 | 🟡 | | [End-to-end typesafe Server-Sent Events](/docs/async-iterator-object) | ✅ | ✅ | 🛑 | | [End-to-end typesafe `ReadableStream`](/docs/binary-data#readablestreamuint8array) | ✅ | 🛑 | 🛑 | | [Typesafe at scale](#type-checking-performance) | ✅ | ✅ | 🛑 | oRPC types errors per procedure or globally: throw a plain `ORPCError`, [declare errors](/docs/error-handling) on a single procedure or a shared builder, or define them once and reuse them everywhere via an [error factory](/docs/error-handling#error-factory). tRPC exposes one global error shape, and Hono only types errors returned with an explicit status code. oRPC is also the only one that types binary data in both directions: tRPC and Hono cover uploads but not typed binary responses. ### API Design & OpenAPI | Feature | oRPC | tRPC | Hono | | --- | --- | --- | --- | | Implementation-first approach | ✅ | ✅ | ✅ | | [Contract-first approach](/docs/contract-first) | ✅ | 🛑 | ✅ | | [OpenAPI spec generation](/docs/openapi/specification) | ✅ | 🟡 | ✅ | | [Standard Schema (Zod, Valibot, ArkType, ...)](/docs/integrations/standard-schema) | ✅ | ✅ | 🟡 | | [Native types (Date, URL, Set, Map, ...)](/docs/rpc/serializer#supported-data-types) | ✅ | 🟡 | 🛑 | | [Custom serializers](/docs/rpc/serializer#custom-serializers) | ✅ | ✅ | 🛑 | | [Bracket notation](/docs/openapi/bracket-notation) | ✅ | 🛑 | 🛑 | | [Lazy router (code splitting, faster cold starts)](/docs/router#lazy-router) | ✅ | ✅ | 🛑 | oRPC serves the same procedures over its [RPC protocol](/docs/rpc/protocol) and RESTful [OpenAPI endpoints](/docs/openapi/handler) and generates the OpenAPI spec natively, while tRPC and Hono depend on extra packages for OpenAPI. Native types like `Date` or `Map` work out of the box in oRPC; tRPC needs a transformer, and Hono has no serialization layer: what you pass to `c.json()` is what you get. ### Integrations & Ecosystem | Feature | oRPC | tRPC | Hono | | --- | --- | --- | --- | | [WebSockets](/docs/adapters/websocket) | ✅ | ✅ | 🟡 | | [Hibernation APIs (Cloudflare WebSocket Hibernation, ...)](/docs/integrations/hibernation) | ✅ | 🛑 | 🛑 | | [Message Port (Electron, browser extensions, workers, ...)](/docs/adapters/message-port) | ✅ | 🟡 | 🛑 | | [Batch requests](/docs/plugins/batch) | ✅ | ✅ | 🛑 | | [Pub/Sub publisher (Redis, Upstash, Durable Objects, ...)](/docs/helpers/publisher) | ✅ | 🟡 | 🛑 | | [Rate limiting (Redis, Upstash, Cloudflare, ...)](/docs/helpers/ratelimit) | ✅ | 🛑 | 🟡 | | [Client plugins and interceptors](/docs/rpc/link) | ✅ | ✅ | 🛑 | | [TanStack Query integration (React)](/docs/integrations/tanstack-query) | ✅ | ✅ | 🟡 | | [TanStack Query integration (Vue, Solid, Svelte, Angular)](/docs/integrations/tanstack-query) | ✅ | 🟡 | 🛑 | | [SWR and Pinia Colada integrations](/docs/integrations/swr) | ✅ | 🟡 | 🟡 | | [AI SDK integration](/docs/integrations/ai-sdk) | ✅ | 🛑 | 🛑 | | [Next.js Server Actions support](/docs/integrations/next) | ✅ | ✅ | 🛑 | | [Built-in plugins (CORS, CSRF, Retry, ...)](/docs/plugins/cors) | ✅ | 🛑 | ✅ | | [Built-in helpers (cookie, encryption, signing, ...)](/docs/helpers/cookie) | ✅ | 🛑 | ✅ | | [NestJS integration](/docs/integrations/nest) | ✅ | 🟡 | 🛑 | | [OpenTelemetry integration](/docs/integrations/opentelemetry) | ✅ | 🛑 | 🟡 | Every oRPC integration above is first-party, while tRPC leaves several to community packages and Hono's typed client is a bare fetch wrapper you wire into query libraries yourself. :::info Coming from tRPC? Follow the [migration guide](/docs/migrations/from-trpc), or [use both together](/docs/integrations/trpc) during a gradual migration. ::: ## Performance :::warning Treat these numbers as reference measurements, not a reason to switch frameworks. All three are fast enough for production. The per-request difference is tiny next to your business logic, database, and network, and results vary across environments and workloads. ::: All numbers come from [middleapi/orpc-benchmarks](https://github.com/middleapi/orpc-benchmarks) and were measured on the same machine with oRPC 2.0.0-beta.35, tRPC 11.18.0, and Hono 4.13.1. Handlers are no-ops with pass-through validation, so the results measure framework overhead only. ### Throughput | Scenario | oRPC | tRPC | Hono | | --- | --- | --- | --- | | RPC over HTTP (req/s avg) | 18,845 | 4,299 | n/a | | RPC over WebSocket (req/s avg) | 23,815 | 5,054 | n/a | | OpenAPI (RESTful) over HTTP (req/s avg) | 18,014 | n/a | 16,932 | oRPC handles about 4.4x as many requests as tRPC over HTTP and 4.7x over WebSocket. oRPC and Hono are close on RESTful throughput, with oRPC about 6% ahead. ### Clinic Doctor Profiles Every run also profiles the server with [Clinic.js Doctor](https://clinicjs.org/doctor/): | Run | CPU (avg) | Memory (RSS) | Detected issues | Full report | | --- | --- | --- | --- | --- | | RPC over HTTP · oRPC | ~104% | 73-91 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-orpc/.clinic/report.clinic-doctor.html) | | RPC over HTTP · tRPC | ~113% | 73-247 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/rpc-trpc/.clinic/report.clinic-doctor.html) | | RPC over WebSocket · oRPC | ~104% | 71-109 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-orpc/.clinic/report.clinic-doctor.html) | | RPC over WebSocket · tRPC | ~41% | 67-75 MB | cpu: performance | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/ws-trpc/.clinic/report.clinic-doctor.html) | | OpenAPI over HTTP · oRPC | ~104% | 73-92 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-orpc/.clinic/report.clinic-doctor.html) | | OpenAPI over HTTP · Hono | ~103% | 71-95 MB | none | [view](https://htmlpreview.github.io/?https://github.com/middleapi/orpc-benchmarks/blob/main/benchmarks/openapi-hono/.clinic/report.clinic-doctor.html) | The profiles show where the throughput gap comes from. Over HTTP, oRPC serves over 4x tRPC's requests at similar CPU usage (~104% vs ~113%), so its per-request CPU cost is several times lower, and its event loop stays responsive: 0.04 ms average delay versus tRPC's 0.70 ms. Memory stays flat between 71 and 109 MB in every oRPC run, while tRPC's HTTP run climbs to 247 MB, more than double oRPC's highest reading, a sign of per-request allocation pressure. oRPC and Hono profile nearly identically, in line with their close throughput. tRPC's low WebSocket CPU is not an advantage: the `cpu: performance` issue means the server cannot keep the CPU busy, so throughput is capped elsewhere in the stack. ### Type-Checking Performance On a large, fully typed project with 3,000 procedures across 1,501 routers, oRPC type-checks about 32% faster than tRPC, uses about 31% less memory, and needs about 19% fewer type instantiations: | Metric | oRPC | tRPC | | --- | --- | --- | | Total time | 1.67s | 2.47s | | Memory used | 469 MB | 677 MB | | Instantiations | 2,198,327 | 2,719,560 | Hono has no column because it does not scale to this size. Its RPC types hit [known limits](https://hono.dev/docs/guides/rpc#known-issues) well before this point and the project becomes effectively unusable, an issue oRPC avoids by design. ### Bundle Size Bundle sizes for a minimal client and server pair, measured with the same versions. tRPC includes `superjson` to match oRPC's built-in native types, and Hono includes `@hono/node-server` to match the Node.js servers in the other bundles. | Metric | oRPC | tRPC | Hono | | --- | --- | --- | --- | | Minified | 46.4 kB | 83.3 kB | 43.3 kB | | Minified + gzip | 14.5 kB | 25.6 kB | 16.4 kB | Measure yourself: [oRPC](https://bundlejs.com/?q=%40orpc%2Fclient%402.0.0-beta.35%2C%40orpc%2Fclient%402.0.0-beta.35%2Ffetch%2C%40orpc%2Fserver%402.0.0-beta.35%2C%40orpc%2Fserver%402.0.0-beta.35%2Fnode&treeshake=%5B%7B+createORPCClient+%7D%5D%2C%5B%7B+RPCLink+%7D%5D%2C%5B%7B+os+%7D%5D%2C%5B%7B+RPCHandler+%7D%5D) · [tRPC](https://bundlejs.com/?q=%40trpc%2Fclient%4011.18.0%2C%40trpc%2Fserver%4011.18.0%2C%40trpc%2Fserver%4011.18.0%2Fadapters%2Fstandalone%2Csuperjson%402.2.6&treeshake=%5B%7B+createTRPCClient%2ChttpLink%2ChttpSubscriptionLink%2CsplitLink+%7D%5D%2C%5B%7B+initTRPC+%7D%5D%2C%5B%7B+createHTTPServer+%7D%5D%2C%5B%7B+default+as+SuperJSON+%7D%5D) · [Hono](https://bundlejs.com/?q=hono%404.13.1%2Chono%404.13.1%2Fclient%2C%40hono%2Fnode-server%402.1.0&treeshake=%5B%7B+Hono+%7D%5D%2C%5B%7B+hc+%7D%5D%2C%5B%7B+serve+%7D%5D) --- # Context Source: https://orpc.dev/docs/context ## Initial Context Use initial context for values that come from the environment. Declare it with `.$context`, then provide it when executing the procedure: ```ts twoslash import { os } from '@orpc/server' // ---cut--- const base = os.$context<{ env: { DB_URL: string } }>() export const getting = base .handler(async ({ context }) => { console.log(context.env) }) ``` :::info When a procedure requires initial context when calling, you must manually pass it: ```ts twoslash import { call, os } from '@orpc/server' const base = os.$context<{ env: { DB_URL: string } }>() const getting = base.handler(async ({ context }) => {}) // ---cut--- const output = await call(getting, undefined, { context: { // <- initial context must be passed when calling env: { DB_URL: 'postgres://...' }, }, }) ``` ::: ### Default Initial Context To avoid repeating `.$context` declarations, you can define a default initial context type globally. ```ts declare module '@orpc/server' { export interface DefaultInitialContext { env: { DB_URL: string } } } ``` ## Injected Context Injected context is injected at runtime through [middleware](/docs/middleware#middleware-context): ```ts twoslash import { os } from '@orpc/server' declare const env: { DB_URL: string } // ---cut--- const base = os.use(async ({ next }) => next({ context: { env: { DB_URL: env.DB_URL }, }, })) export const getting = base.handler(async ({ context }) => { console.log(context.env) }) ``` :::info When you use middleware context, you do not need to pass context manually when calling: ```ts twoslash import { call, os } from '@orpc/server' declare const env: { DB_URL: string } const base = os.use(async ({ next }) => next({ context: { env: { DB_URL: env.DB_URL }, }, })) const getting = base.handler(async ({ context }) => {}) // ---cut--- // no need to pass context manually when calling const output = await call(getting) ``` ::: ## Combining Initial and Injected Context In many cases, you will use both. Use initial context for environment-specific values, such as database URLs, and injected context for runtime data, such as authenticated users. ```ts twoslash import { ORPCError, os } from '@orpc/server' declare function parseJWT(token: string | undefined, secret: string): { userId: number } | null // ---cut--- const base = os.$context<{ headers: Headers, env: { JWT_SECRET: string } }>() const requireAuth = base.middleware(async ({ context, next }) => { const user = parseJWT( context.headers.get('authorization')?.split(' ')[1], context.env.JWT_SECRET ) if (!user) { throw new ORPCError('UNAUTHORIZED') } return next({ context: { user } }) }) const getting = base .use(requireAuth) .handler(async ({ context }) => { console.log(context.env) console.log(context.user) }) ``` --- # Contract-First Source: https://orpc.dev/docs/contract-first In the [Getting Started](/docs/getting-started) guide, each procedure is defined and implemented in one place, and the client's types come from the server's code. Contract-first splits this into two steps. First you write a **contract**: a description of every procedure, its input, and its output, with no logic inside. Then you implement the contract, and TypeScript checks that the implementation matches it exactly. This is worth the extra step when: - You want to agree on the API design before writing code, so frontend and backend work can start at the same time. - The client lives in another repository or team, and it should depend on the API's shape, not on server code. - You want one source of truth the implementation can never drift away from. This guide follows the same path as Getting Started, in contract-first order: 1. Define a contract that describes your API. 2. Implement the contract on the server and serve it over HTTP. 3. Call it from a fully typed client built from the contract alone. ## Installation Contract-first adds one package to the usual setup: `@orpc/contract`, which holds the contract builder. As before, you also need a schema library such as [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [ArkType](https://arktype.io/), or any other [Standard Schema](https://standardschema.dev/) library. ```package-install npm install @orpc/contract@beta @orpc/server@beta @orpc/client@beta zod ``` ## Define a Contract A contract describes a procedure without implementing it. Build one with the `oc` builder (short for oRPC contract): declare the input with `.input`, the output with `.output`, and stop there. A contract has no `.handler`. Group contracts into a plain object, just like procedures form a router. ```ts twoslash import { oc } from '@orpc/contract' import * as z from 'zod' const PlanetSchema = z.object({ id: z.number(), name: z.string(), description: z.string().optional(), }) export const listPlanetsContract = oc .output(z.array(PlanetSchema)) export const findPlanetContract = oc .input(z.object({ id: z.number() })) .output(PlanetSchema) export const createPlanetContract = oc .input(z.object({ name: z.string(), description: z.string().optional() })) .output(PlanetSchema) export const contract = { planet: { list: listPlanetsContract, find: findPlanetContract, create: createPlanetContract, }, } ``` A few things to notice: - `.output` matters here. In Getting Started, the client's result type flows from the handler's return value. A contract has no handler, so `.output` is where that type comes from. Skip it and the result type becomes `unknown`. - `.output` is not just a type: once implemented, the server validates every response against it at runtime. - Contracts can also declare typed [errors](/docs/error-handling#typesafe-errors) and [metadata](/docs/metadata). Learn more in the [Procedure Contract documentation](/docs/contract/procedure). Keep this file free of server code. That is what lets the client import it safely later. ## Implement the Contract The `implement` function turns your contract into a builder that already knows every procedure's shape. We name it `os` because it works just like the `os` builder from Getting Started, except it is locked to your contract. ```ts twoslash import { contract } from './shared/contract-first' // ---cut--- import { implement } from '@orpc/server' const os = implement(contract) export const listPlanets = os.planet.list .handler(async () => { // replace with your database query return [ { id: 1, name: 'Earth' }, { id: 2, name: 'Mars' }, ] }) export const findPlanet = os.planet.find .handler(async ({ input }) => { // replace with your database query return { id: input.id, name: 'Earth' } }) export const createPlanet = os.planet.create .handler(async ({ input }) => { // replace with your database insert return { id: 3, ...input } }) export const router = os.router({ planet: { list: listPlanets, find: findPlanet, create: createPlanet, }, }) ``` The contract does the heavy lifting: - `os.planet.find` already knows its input and output types, so you only write the logic. Input is still validated before the handler runs. - Return the wrong shape from a handler and TypeScript reports an error immediately. - `os.router` checks completeness: forget to implement a procedure, or put it under the wrong key, and the code does not compile. Implementations can use [middleware](/docs/middleware) and [context](/docs/context) as usual. Learn more in the [Contract Implementation documentation](/docs/contract/implementation). ## Create a Server Serving the router works exactly as in [Getting Started](/docs/getting-started#create-a-server): `RPCHandler` matches each request to a procedure, validates the input, runs your handler, and sends the result back. ```ts twoslash /// import { router } from './shared/contract-first' // ---cut--- import { createServer } from 'node:http' import { RPCHandler } from '@orpc/server/node' const handler = new RPCHandler(router) const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc' }) if (matched) { return } res.statusCode = 404 res.end('Not found') }) server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) ``` :::info To serve the same contract as a REST API, add [routing metadata](/docs/openapi/routing) such as `.meta(openapi({ method: 'GET', path: '/planets' }))` to the contract itself, serve it with the [OpenAPI Handler](/docs/openapi/handler), and generate an [OpenAPI specification](/docs/openapi/specification) straight from the contract. ::: ## Create a Client Here is the payoff. Type the client with `RouterContractClient`: it needs only the contract, which contains no business logic, so no server code can leak into the client bundle. ```ts twoslash import type { contract } from './shared/contract-first' // ---cut--- import type { RouterContractClient } from '@orpc/contract' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ origin: 'http://127.0.0.1:3000', url: '/rpc', // <- must match the server's prefix }) export const orpc: RouterContractClient = createORPCClient(link) ``` :::tip Because the contract is plain data plus schemas, you can move it into a shared package that both sides depend on, or even [publish a typed client to npm](/docs/recipes/publish-client-to-npm) for third parties. Learn more in the [Client-Side Clients documentation](/docs/client/client-side). ::: ## Call a Procedure Calling procedures feels exactly the same as in Getting Started: ```ts twoslash import { client as orpc } from './shared/contract-first' // ---cut--- const planets = await orpc.planet.list() const planet = await orpc.planet.find({ id: 1 }) orpc.planet.create // ^| // // ``` Both sides now answer to the contract. The server cannot ship a response the contract does not allow, the client cannot send input the contract rejects, and changing the contract surfaces every affected handler and call site as a compile error. ## Next Steps - Learn contracts in depth: [Procedure Contract](/docs/contract/procedure) and [Router Contract](/docs/contract/router) - Add middleware and context to implementations in [Contract Implementation](/docs/contract/implementation) - Already have an OpenAPI spec? [Generate a contract from it](/docs/contract/generate-from-openapi) - Keep type checking fast in large codebases with the [Contract Client Factory](/docs/contract/client-factory) - Expose the contract as a REST API with the [OpenAPI Handler](/docs/openapi/handler) and share it via an [OpenAPI specification](/docs/openapi/specification) --- # Contract Client Factory Source: https://orpc.dev/docs/contract/client-factory A single root [client](/docs/client/client-side) is a great way to get started. As your project grows, though, it can lead to type performance issues and tangled dependencies. Splitting the client into smaller service-level clients can help for a while, but very large codebases can still outgrow that approach. ## Requirements This pattern depends on one consistency rule: every [procedure contract](/docs/contract/procedure) must define `meta.path`, and that path must exactly match the procedure's location in the root contract. ```ts import { meta, oc } from '@orpc/contract' export const procedure = oc .meta(meta.path(['real', 'path', 'to', 'procedure'])) .input(z.object({ name: z.string() })) .output(z.object({ message: z.string() })) ``` If you use `['real', 'path', 'to', 'procedure']` as the path, the procedure must be mounted at `real.path.to.procedure` in the root contract. This is required for the pattern to work correctly: ```ts import { procedure } from './path/to/procedure' const router = { real: { path: { to: { procedure, }, }, } } ``` ## Contract Client Factory This pattern does not require a single root client. Instead, you configure a client factory that communicates with the server. `createContractClientFactory` accepts an [RPC Link](/docs/rpc/link), an [OpenAPI Link](/docs/openapi/link), or a custom link. It also accepts options similar to [`createORPCClient`](/docs/client/client-side), but with less typesafe because the full contract is not known up front: ```ts import { createContractClientFactory } from '@orpc/contract' export const createClient = createContractClientFactory(link, { /** options */}) ``` :::warning If you are using [OpenAPI Link](/docs/openapi/link), or any link that requires the client to be wrapped in `JsonifiedClient`, use `createContractJsonifiedClientFactory` from `@orpc/openapi` instead of `createContractClientFactory`. ::: You can then create a client by importing a procedure contract directly in the client: ```ts import { procedure } from './path/to/procedure' const client = createClient(procedure) const output = await client(input, {/** options */}) ``` The factory also accepts a [router contract](/docs/contract/router), returning a client that mirrors its shape. Procedure paths follow the router shape, prefixed with a base path derived from the first procedure that defines `meta.path`, so passing a sub-router of the root contract works too: ```ts import { router } from './path/to/router' const client = createClient(router) const output = await client.path.to.procedure(input, {/** options */}) ``` ### `contractRef` Some integrations still need a root contract. For example, [OpenAPI Link](/docs/openapi/link) and some plugins depend on one. In those cases, `contractRef` can help: ```ts import { RouterContract } from '@orpc/contract' const contractRef: RouterContract = {} const link = new OpenAPILink(contractRef, { plugins: [ new PluginRequireContract(contractRef) ] }) export const createClient = createContractJsonifiedClientFactory(link, { contractRef }) ``` The idea behind `contractRef` is simple: every time `createClient` is used, the factory automatically registers every procedure contract inside the passed contract into `contractRef` at its resolved path. :::info Some features may not support `contractRef` well. In those cases, import the root contract instead and cast it with `as any` when needed. ::: ## TanStack Query Integration [TanStack Query Integration](/docs/integrations/tanstack-query) also supports this pattern. First, create a factory that accepts a [contract client factory](#contract-client-factory) and options similar to the [TanStack Query interceptor options](/docs/integrations/tanstack-query#interceptors), but with less type safety because the full contract is not known up front: ```ts import { createContractUtilsFactory } from '@orpc/tanstack-query' export const createUtils = createContractUtilsFactory(createClient, { /** options */}) ``` :::warning If you are using [OpenAPI Link](/docs/openapi/link), or any link that requires the client to be wrapped in `JsonifiedClient`, use `createContractJsonifiedUtilsFactory` from `@orpc/tanstack-query` instead of `createContractUtilsFactory`. ::: You can then create utilities for each procedure contract: ```ts import { procedure } from './path/to/procedure' const utils = createUtils(procedure) const query = useQuery(utils.queryOptions({/** options */})) ``` Like the [contract client factory](#contract-client-factory), it also accepts a [router contract](/docs/contract/router), returning utilities that mirror its shape: ```ts import { router } from './path/to/router' const utils = createUtils(router) const query = useQuery(utils.path.to.procedure.queryOptions({/** options */})) ``` ## Pinia Colada Integration [Pinia Colada Integration](/docs/integrations/pinia-colada) also supports this pattern. First, create a factory that accepts a [contract client factory](#contract-client-factory) and options similar to the [Pinia Colada interceptor options](/docs/integrations/pinia-colada#interceptors), but with less type safety because the full contract is not known up front: ```ts import { createContractUtilsFactory } from '@orpc/pinia-colada' export const createUtils = createContractUtilsFactory(createClient, { /** options */}) ``` :::warning If you are using [OpenAPI Link](/docs/openapi/link), or any link that requires the client to be wrapped in `JsonifiedClient`, use `createContractJsonifiedUtilsFactory` from `@orpc/pinia-colada` instead of `createContractUtilsFactory`. ::: You can then create utilities for each procedure contract: ```ts import { procedure } from './path/to/procedure' const utils = createUtils(procedure) const query = useQuery(utils.queryOptions({/** options */})) ``` Like the [contract client factory](#contract-client-factory), it also accepts a [router contract](/docs/contract/router), returning utilities that mirror its shape: ```ts import { router } from './path/to/router' const utils = createUtils(router) const query = useQuery(utils.path.to.procedure.queryOptions({/** options */})) ``` --- # Generate Contract from OpenAPI Source: https://orpc.dev/docs/contract/generate-from-openapi ## Overview If you already have an [OpenAPI specification](https://swagger.io/specification/), you can generate the contract with [Hey API](https://heyapi.dev/)'s `orpc` plugin instead of defining it manually. Each operation in the specification becomes a [procedure contract](/docs/contract/procedure) with its route, input, and output. :::warning The Hey API `orpc` plugin is currently beta and may introduce breaking changes while the integration stabilizes. Until the next stable Hey API release, oRPC v2 output requires the `next` release tag. ::: ## Example Install Hey API: ```package-install npm install -D @hey-api/openapi-ts@next ``` Create an `openapi-ts.config.ts` file pointing at your specification. It can be a local file or a URL: ```ts openapi-ts.config.ts import { defineConfig } from '@hey-api/openapi-ts' export default defineConfig({ input: 'https://get.heyapi.dev/hey-api/backend', output: 'src/contract', plugins: [ { name: 'orpc', compatibilityVersion: '2', validator: 'zod', }, ], }) ``` Then run: ```bash npx @hey-api/openapi-ts ``` This writes `orpc.gen.ts` and `zod.gen.ts` to `src/contract`, with one procedure contract per operation and a `contract` router combining them all. In this example, `zod` generates the validation schemas: ```ts src/contract/orpc.gen.ts import { oc } from '@orpc/contract' import { openapi } from '@orpc/openapi' import * as z from 'zod' import { zAddPetBody, zAddPetResponse } from './zod.gen' export const addPet = oc .meta(openapi({ inputStructure: 'detailed', method: 'POST', path: '/pet', tags: ['pet'], })) .input(z.object({ body: zAddPetBody })) .output(zAddPetResponse) export const contract = { addPet, // ...every other operation } ``` The generated files import `@orpc/contract`, `@orpc/openapi`, and `zod`, so install them if you have not already: ```package-install npm install @orpc/contract@beta @orpc/openapi@beta zod ``` For all configuration options and plugin behavior, see the [Hey API `orpc` plugin documentation](https://heyapi.dev/docs/openapi/typescript/plugins/orpc/v2). ## What To Do Next Once the contract is generated, what you do next depends on how you want to use it: - Implement the contract on your own server with [Contract Implementation](/docs/contract/implementation). - Call an existing OpenAPI-compliant server through a typesafe client with [OpenAPI Link](/docs/openapi/link). --- # Contract Implementation Source: https://orpc.dev/docs/contract/implementation ## Implementer The `implement` function turns a contract into an implementer. Use it to build procedures, routers, and create middleware with full type safety. ```ts twoslash import { contract } from './shared/planet' // ---cut--- import { implement } from '@orpc/server' const implementer = implement(contract) .$context<{ something?: string }>() // <- define initial context implementer.planet.list // ^| // // // // // ``` ### Initial Context Use `.$context` to declare the initial context required for a procedure to execute. Learn more in the [Context Documentation](/docs/context). ## Implementing Procedures Define a `.handler` for a procedure contract to provide its business logic. ```ts twoslash import { contract } from './shared/planet' import { implement } from '@orpc/server' const implementer = implement(contract) const requireAuth = implementer.middleware(({ next }) => next()) // ---cut--- const listPlanet = implementer.planet.list .use(requireAuth) // <- Apply authentication middleware .handler(({ input }) => { // Your logic for listing planets return [] }) ``` :::info If middleware needs to wrap validation, apply it at the router level instead. In this example, use `implementer.use` to apply it globally or `implementer.planet.use` to apply it to the `planet` router before `.list`. ```ts const listPlanet = implementer .planet .use(requireAuth) // <- middleware wraps validation .list .handler(({ input }) => { // Your logic for listing planets return [] }) ``` ::: ## Implementing Routers Create the root router with `.router` to assemble your API. This enables full type-checking and runtime contract enforcement. ```ts const router = implementer.router({ planet: { list: listPlanet, find: findPlanet, create: createPlanet, }, }) ``` ### Extending Router Like a normal [router](/docs/router), an implementer router can also be extended with shared behavior. For example, you can apply authentication middleware to every procedure: ```ts const router = implementer.use(requireAuth).router({ planet: { list: listPlanet, find: findPlanet, create: createPlanet, }, }) ``` :::danger If you apply middleware with `.use` at both the router and procedure levels, it may run more than once. That duplication can hurt performance. To avoid redundant middleware execution, see the [Dedupe Middleware](/docs/recipes/dedupe-middleware) recipe. ::: ## Creating Middleware The implementer can also create [middleware](/docs/middleware). Middleware created this way can infer the contract's [typesafe errors](/docs/error-handling#typesafe-errors). If not all contracts define the same errors, use the `in` operator to check that an error exists before using it. ```ts const ratelimit = implementer.middleware(async ({ next, errors }) => { if ('TOO_MANY_REQUESTS' in errors) { // Apply rate limiting only when TOO_MANY_REQUESTS is defined by the contract. if (isRatelimitReached) { throw errors.TOO_MANY_REQUESTS() } } return next() }) ``` :::info You do not have to create middleware from the implementer. Any type-compatible middleware can be used. ::: ## Reusability Each implementer call creates a new instance, which avoids reference issues and makes contracts easy to reuse and extend. ```ts const pub = implementer // Base setup for procedures that publish const authed = implementer.use(requireAuth) // Extends 'pub' with authentication const listPlanets = pub.planet.list.handler(({ input }) => { // Your logic for listing planets without authentication return [] }) const createPlanet = authed.planet.create.handler(({ input }) => { // Your logic for creating planets with authentication return { } }) ``` This pattern helps prevent duplication while maintaining flexibility. --- # Procedure Contract Source: https://orpc.dev/docs/contract/procedure ## Overview ```ts twoslash import { z } from 'zod' import type { AnyMetaPlugin } from '@orpc/contract' declare const someMeta: AnyMetaPlugin // ---cut--- import { oc } from '@orpc/contract' const example = oc .meta(someMeta) // <- attach metadata .errors({ NOT_FOUND: {} }) // <- define errors .input(z.object({ id: z.number(), name: z.string() })) // <- input validation .output(z.object({ id: z.number(), name: z.string() })) // <- output validation ``` :::info All of these chains are optional. You can create an empty contract with just `oc`. ::: ## Metadata Use `.meta` to attach metadata to a contract. Middleware and plugins can read it later when you implement the contract. Learn more in the [Metadata documentation](/docs/metadata). ## Typesafe Errors Use `.errors` to define the errors a contract can produce. These errors can be thrown from handlers or middleware when you implement the contract and remain properly typed on the client. Learn more in the [Typesafe Error Handling documentation](/docs/error-handling#typesafe-errors). ## Input/Output Validation oRPC supports [Zod](https://zod.dev/), [Valibot](https://valibot.dev/), [Arktype](https://arktype.io/), and any other [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library for validation. :::info Unlike a [procedure](/docs/procedure), a contract has no `.handler` chain. If you want the client to infer the output type correctly, define `.output`. Otherwise, the output type will be `unknown`. ::: ### Multiple Schemas `.input` and `.output` can be called multiple times. Each call adds another schema instead of replacing an earlier one, and the value must satisfy all of them. ```ts const base = oc .input(z.object({ name: z.string() })) .output(z.looseObject({ name: z.string() })) const example = base .input(z.object({ id: z.number() })) .output(z.looseObject({ id: z.number() })) ``` Object input schemas compose into a single flat value instead of being piped into each other, so a contract can extend a base contract without repeating its fields. Output schemas are still piped. Learn more in the [Procedure documentation](/docs/procedure#multiple-schemas). ### `type` Utility For simple use cases without external libraries, use oRPC's built-in `type` utility. It takes a mapping function as its first argument: ```ts import { type } from '@orpc/contract' const example = oc .input(type<{ value: number }>()) .output(type<{ value: number }, number>(({ value }) => value)) ``` ## Reusability Each builder call creates a new instance, which avoids reference issues and makes contracts easy to reuse and extend. ```ts const pub = oc // Base setup for procedures that publish const authed = pub.meta(requireAuthMeta) // Extends 'pub' with authentication const pubExample = pub .input(z.object({ name: z.string() })) const authedExample = authed .input(z.object({ id: z.number() })) ``` This pattern helps prevent duplication while maintaining flexibility. --- # Router Contract Source: https://orpc.dev/docs/contract/router :::info A standalone [procedure contract](/docs/contract/procedure) is also a router contract, so you can use the same features with individual procedure contracts. ::: ## Overview Define a router contract as a plain JavaScript object where each key maps to a procedure contract: ```ts twoslash import { z } from 'zod' // ---cut--- import { oc } from '@orpc/contract' const ping = oc.output(z.string()) const pong = oc.output(z.string()) export const router = { ping, pong, nested: { ping, pong } } ``` :::warning For compatibility, do not use these router keys: `then`, `bind`, `call`, `apply`, `valueOf`, `toString`, `toJSON`. ::: ## Extending Router You can extend a router contract with shared configuration, such as attaching metadata to every procedure: ```ts const router = oc.meta(requireAuthMeta).router({ ping, pong, nested: { ping, pong, } }) ``` ## Router to Contract A normal [router](/docs/router) can be used as a contract router as long as it does not include a [lazy router](/docs/router#lazy-router). If necessary, use `unlazyRouter` to fully resolve it and make it contract-compatible. ```ts import { unlazyRouter } from '@orpc/server' const compatibleContract = await unlazyRouter(router) ``` ### Safely Importing Router on the Client Sometimes you need to import the contract on the client, for example when using [OpenAPI Link](/docs/openapi/link). If you derive the contract from a [router](/docs/router), importing it directly can be heavy and may expose internal logic. To avoid this, follow the steps below to safely minify and export the contract. 1. **Minify the Contract Router and Export to JSON** ```ts import fs from 'node:fs' import { unlazyRouter } from '@orpc/server' import { minifyRouterContract } from '@orpc/contract' const compatibleContract = await unlazyRouter(router) const minifiedRouter = minifyRouterContract(compatibleContract) fs.writeFileSync('./contract.json', JSON.stringify(minifiedRouter)) ``` ::: info `minifyRouterContract` preserves only the metadata needed by the client; all other data is stripped out. ::: 2. **Import the Contract JSON on the Client Side** ```ts import contract from './contract.json' // [!code highlight] const link = new OpenAPILink(contract as typeof router) ``` ::: info Cast `contract` to `typeof router` to preserve type safety, since standard schema types cannot be serialized to JSON and must be cast manually. ::: :::tip Instead of regenerating the JSON file manually whenever the router changes, you can run the same steps in a build-time macro. See [OpenAPI Link Without Runtime Imports](/docs/openapi/link-without-runtime-imports). ::: ## Utilities :::info A standalone [procedure contract](/docs/contract/procedure) is also a router contract, so these utilities work with individual procedure contracts too. ::: ### Infer Router Contract Inputs Infers the input type of each procedure contract in a router contract. ```ts twoslash import type { contract } from './shared/planet' // ---cut--- import type { InferRouterContractInputs } from '@orpc/contract' export type Inputs = InferRouterContractInputs type FindPlanetInput = Inputs['planet']['find'] ``` ### Infer Router Contract Outputs Infers the output type of each procedure contract in a router contract. ```ts twoslash import type { contract } from './shared/planet' // ---cut--- import type { InferRouterContractOutputs } from '@orpc/contract' export type Outputs = InferRouterContractOutputs type FindPlanetOutput = Outputs['planet']['find'] ``` ### Infer Router Contract Error Map Collects the error maps from every procedure contract in a router contract into a single type. ```ts twoslash import type { contract } from './shared/planet' // ---cut--- import type { InferRouterContractErrorMap } from '@orpc/contract' export type ErrorMap = InferRouterContractErrorMap ``` ### Infer Router Contract Errors Infers the throwable errors each procedure contract in a router contract can describe. ```ts twoslash import type { contract } from './shared/planet' // ---cut--- import type { InferRouterContractErrors } from '@orpc/contract' export type Errors = InferRouterContractErrors type FindPlanetError = Errors['planet']['find'] ``` ### Infer Router Contract Error Infers all possible throwable errors the entire router contract can describe. This is useful when you want a single type for contract-wide error handling. ```ts twoslash import type { contract } from './shared/planet' // ---cut--- import type { InferRouterContractError } from '@orpc/contract' export type ContractError = InferRouterContractError ``` --- # Ecosystem Source: https://orpc.dev/docs/ecosystem :::info Built something on top of oRPC? [Open a pull request](https://github.com/middleapi/orpc/edit/main/apps/content/docs/ecosystem.mdx) to add it here. These packages are maintained by their authors, not by the oRPC core team, so report issues in their own repositories. ::: ## AI ## Tooling --- # Error Handling Source: https://orpc.dev/docs/error-handling ## `ORPCError` Class `ORPCError` is the standard error type in oRPC. It includes a `code`, plus optional `message` and `data` fields. :::danger `message` and `data` are sent to the client. Do not include sensitive information in either field. ::: ```ts twoslash declare const notFound: boolean // ---cut--- import { ORPCError, os } from '@orpc/server' const rateLimitMiddleware = os.middleware(async ({ next }) => { throw new ORPCError('RATE_LIMITED', { message: 'You are being rate limited', data: { retryAfter: 60 } }) return next() }) const example = os .use(rateLimitMiddleware) .handler(async ({ input }) => { if (notFound) { throw new ORPCError('NOT_FOUND') } }) ``` ## Typesafe Errors For end-to-end type safety, define your errors with `.errors`. This lets the client infer each error's shape and handle it safely. You can use any [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec) library to validate error data. :::danger `message` and `data` are sent to the client. Do not include sensitive information in either field. ::: ```ts twoslash import { os } from '@orpc/server' import * as z from 'zod' declare const notFound: boolean // ---cut--- const rateLimitMiddleware = os .errors({ RATE_LIMITED: { data: z.object({ retryAfter: z.number(), }), }, }) .middleware(async ({ next, errors }) => { throw errors.RATE_LIMITED({ message: 'You are being rate limited', data: { retryAfter: 60 } }) return next() }) const exampleProcedure = os .use(rateLimitMiddleware) .errors({ NOT_FOUND: { message: 'The resource was not found', // <- default message }, }) .handler(async ({ input, errors }) => { if (notFound) { throw errors.NOT_FOUND() } }) ``` :::tip You can use typesafe errors across your entire project, but we recommend reserving them for application-specific cases. For common errors like `UNAUTHORIZED` or `RATE_LIMITED`, the client usually already understands the meaning. Skipping explicit schemas for those errors can also reduce type complexity and improve TypeScript performance. ::: ### ORPCError Compatibility If you cannot access the `errors` object, for example in a utility function or another module, you can still throw `ORPCError`. oRPC will try to convert it to the matching typesafe error when its `code` and `data` match a defined error. If no match is found, it is treated as an unknown error. ```ts const exampleProcedure = os .errors({ NOT_FOUND: { message: 'The resource was not found', }, }) .handler(async ({ errors }) => { throw errors.NOT_FOUND() // Treated as errors.NOT_FOUND because the code and data match throw new ORPCError('NOT_FOUND') // Treated as an unknown error because it does not match any defined error throw new ORPCError('BAD_REQUEST') }) ``` ## Error Factory An error factory lets you define an error once and reuse it anywhere, keeping error handling consistent across your project. ```ts import { error } from '@orpc/server' const RateLimitedError = error('RATE_LIMITED', { /** * Optional default message, can be overridden when constructing an error. */ message: 'You are being rate limited', /** * Optional schema used to type and validate the error data. * Must be a synchronous schema. */ data: z.object({ retryAfter: z.number(), }), }) const procedure = os .handler(async () => { throw new RateLimitedError({ data: { retryAfter: 60 } }) }) ``` :::tip You can also register error factories in `.errors`. This makes them part of the [typesafe errors](#typesafe-errors) flow and visible in generated specifications. ```ts const procedure = os .errors({ [RateLimitedError.code]: RateLimitedError, }) ``` ::: ### `instanceof` Support An error factory class supports `instanceof` checks with full type narrowing. It matches any `ORPCError` with the same `code` whose `data` passes the schema. ```ts if (err instanceof RateLimitedError) { console.log(err.data.retryAfter) } ``` ## ORPC Error Codes By default, oRPC allows any string as an error code and suggests common HTTP codes like `NOT_FOUND` and `UNAUTHORIZED`. You can override this with your own set of allowed error codes for better type safety and consistency. ```ts declare module '@orpc/server' { // or '@orpc/client' interface Registry { ORPCErrorCode: 'NOT_FOUND' | 'UNAUTHORIZED' | 'RATE_LIMITED' | 'MY_CUSTOM_ERROR' | (string & {}) } } ``` With this configuration, only `NOT_FOUND`, `UNAUTHORIZED`, `RATE_LIMITED`, and `MY_CUSTOM_ERROR` will be suggested as error codes. The `(string & {})` fallback ensures you can still use any string value when needed. ## Using Custom Error Classes You do not have to use `ORPCError` directly in your business logic. You can throw your own error classes and convert them to `ORPCError` in middleware or interceptors. :::info By default, oRPC can convert non-`ORPCError` into an `ORPCError` with code `INTERNAL_SERVER_ERROR`, or leave them unchanged depending on the client you are using. ::: ```ts class MyCustomError extends Error { } const customErrorConverterMiddleware = os.middleware(async ({ next }) => { try { return await next() } catch (err) { if (err instanceof MyCustomError) { throw new ORPCError('MY_CUSTOM_ERROR', { message: err.message, cause: err }) } throw err } }) ``` ## Client Error Handling To learn how to handle errors on the client side, see the [Client Error Handling documentation](/docs/client/error-handling). --- # Getting Started Source: https://orpc.dev/docs/getting-started Building an API usually means defining HTTP endpoints on the server, calling them from the client, and keeping both sides' types in sync by hand. oRPC removes that gap: you write plain TypeScript functions on the server, and clients call them like local functions. Input is validated at runtime, types flow end to end, and there is no code generation step. This guide takes the shortest path through oRPC: 1. Define procedures (the functions of your API) and group them into a router. 2. Serve the router over HTTP. 3. Call it from a fully typed client. :::tip[Prefer a running example?] Open one of the [playgrounds](/docs/playgrounds) in StackBlitz and follow along in a complete project. ::: ## Installation Install the server and client packages, plus a schema library for validating input at runtime. This guide uses [Zod](https://zod.dev/), but [Valibot](https://valibot.dev/), [ArkType](https://arktype.io/), and any other [Standard Schema](https://standardschema.dev/) library work the same way. ```package-install npm install @orpc/server@beta @orpc/client@beta zod ``` ## Define a Router A procedure is a function that clients can call remotely. Build one with the `os` builder (short for oRPC server): optionally describe the input it accepts with a schema, then implement it with `.handler`. A router is a plain object that groups procedures and gives each one its calling path, like `planet.list`. ```ts twoslash import { os } from '@orpc/server' import * as z from 'zod' export const listPlanets = os .handler(async () => { // replace with your database query return [ { id: 1, name: 'Earth' }, { id: 2, name: 'Mars' }, ] }) export const findPlanet = os .input(z.object({ id: z.number() })) .handler(async ({ input }) => { // replace with your database query return { id: input.id, name: 'Earth' } }) export const createPlanet = os .input(z.object({ name: z.string(), description: z.string().optional() })) .handler(async ({ input }) => { // replace with your database insert return { id: 3, ...input } }) export const router = { planet: { list: listPlanets, find: findPlanet, create: createPlanet, }, } ``` A few things to notice: - `.input` validates each call before your handler runs and types `input` inside it. `listPlanets` skips it: a procedure without `.input` simply takes no arguments. - No `.output` schema is needed: the client's result type flows straight from the handler's return type. - Procedures can do much more: share [middleware](/docs/middleware), require [context](/docs/context) such as an authenticated user, and declare typed [errors](/docs/error-handling). Learn more in the [Procedure documentation](/docs/procedure). ## Create a Server Clients reach your router through an HTTP server. `RPCHandler` does the translation: it matches each incoming request to a procedure, validates the input, runs your handler, and sends the result back. This example uses [Node's built-in HTTP module](/docs/adapters/node-http). The same router also runs on Bun and Deno through the [Fetch API adapter](/docs/adapters/fetch-api), and on [Cloudflare Workers](/docs/adapters/cloudflare-workers). ```ts twoslash /// import { router } from './shared/getting-started' // ---cut--- import { createServer } from 'node:http' import { RPCHandler } from '@orpc/server/node' const handler = new RPCHandler(router) const server = createServer(async (req, res) => { const { matched } = await handler.handle(req, res, { prefix: '/rpc' }) if (matched) { return } res.statusCode = 404 res.end('Not found') }) server.listen(3000, '127.0.0.1', () => console.log('Listening on 127.0.0.1:3000')) ``` Every procedure is now reachable under the `/rpc` prefix. Requests that are not oRPC calls fall through, so you can handle them yourself, here with a plain 404. For CORS, logging, and other options, see the [RPC Handler documentation](/docs/rpc/handler). :::info oRPC can also serve the same router as a REST API. Add [routing metadata](/docs/openapi/routing) to your procedures, serve them with the [OpenAPI Handler](/docs/openapi/handler), and generate an [OpenAPI specification](/docs/openapi/specification) from the same definitions. ::: ## Create a Client On the client, `RPCLink` is the counterpart of `RPCHandler`: it turns your function calls into HTTP requests. Pass it to `createORPCClient`, and type the result with `RouterClient` so the client knows every procedure, its input, and its output. ```ts twoslash import type { router } from './shared/getting-started' // ---cut--- import type { RouterClient } from '@orpc/server' import { createORPCClient } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ origin: 'http://127.0.0.1:3000', url: '/rpc', // <- must match the server's prefix }) export const orpc: RouterClient = createORPCClient(link) ``` :::tip The client only needs the router's type. Import it with `import type`, or export the `RouterClient` type from the server, so no server code ends up in your client bundle. Learn more in the [Client-Side Clients documentation](/docs/client/client-side). ::: When the caller runs in the same process as the server, for example during server-side rendering, skip HTTP entirely with a [server-side client](/docs/client/server-side). ## Call a Procedure That is the whole setup. Call your procedures like local functions and let your editor do the rest: ```ts twoslash import { client as orpc } from './shared/getting-started' // ---cut--- const planets = await orpc.planet.list() const planet = await orpc.planet.find({ id: 1 }) orpc.planet.create // ^| // // ``` `planet` is typed from the handler's return value, invalid input is rejected before your handler runs, and renaming a procedure on the server is a compile error in the client. There is no generated code to keep in sync. ## Next Steps - Learn the building blocks in depth: [Procedure](/docs/procedure) and [Router](/docs/router) - Add authentication and logging with [Middleware](/docs/middleware) and [Context](/docs/context), and reject calls with typed [errors](/docs/error-handling) - Stream typed events over Server-Sent Events (SSE) with [AsyncIteratorObject](/docs/async-iterator-object) - Expose the same router as a REST API with the [OpenAPI Handler](/docs/openapi/handler) and generate its [OpenAPI specification](/docs/openapi/specification) - Define your API as a [contract first](/docs/contract-first), then let TypeScript enforce the implementation - Integrate with your stack: [TanStack Query](/docs/integrations/tanstack-query), [SWR](/docs/integrations/swr), [Pinia Colada](/docs/integrations/pinia-colada), [Next.js](/docs/integrations/next), and [NestJS](/docs/integrations/nest) --- # Base64Url Helpers Source: https://orpc.dev/docs/helpers/base64url ## Basic Usage ```ts twoslash import { decodeBase64url, encodeBase64url } from '@orpc/server/helpers' const originalText = 'Hello World' const textBytes = new TextEncoder().encode(originalText) const encodedData = encodeBase64url(textBytes) const decodedBytes = decodeBase64url(encodedData) const decodedText = new TextDecoder().decode(decodedBytes) // 'Hello World' ``` :::info The `decodeBase64url` accepts `undefined` or `null` as encoded value and returns `undefined` for invalid inputs, enabling seamless handling of optional data. ::: --- # Cookie Helpers Source: https://orpc.dev/docs/helpers/cookie ## Basic Usage ```ts twoslash import { deleteCookie, getCookie, setCookie } from '@orpc/server/helpers' const reqHeaders = new Headers() const resHeaders = new Headers() setCookie(resHeaders, 'sessionId', 'abc123', { secure: true, maxAge: 3600 }) deleteCookie(resHeaders, 'sessionId') const sessionId = getCookie(reqHeaders, 'sessionId') ``` :::info Both helpers accept `undefined` as headers for seamless integration with plugins like [Request Headers](/docs/plugins/request-headers) or [Response Headers](/docs/plugins/response-headers). ::: ## Security with Signing and Encryption Combine cookies with [signing](/docs/helpers/signing) or [encryption](/docs/helpers/encryption) for enhanced security: ```ts twoslash import { getCookie, setCookie, sign, unsign } from '@orpc/server/helpers' const secret = 'your-secret-key' const reqHeaders = new Headers() const resHeaders = new Headers() setCookie(resHeaders, 'sessionId', await sign('abc123', secret), { httpOnly: true, secure: true, maxAge: 3600 }) const signedSessionId = await unsign(getCookie(reqHeaders, 'sessionId'), secret) ``` --- # Encryption Helpers Source: https://orpc.dev/docs/helpers/encryption :::warning Encryption secures data content but has performance trade-offs compared to [signing](/docs/helpers/signing). It requires more CPU resources and processing time. For edge runtimes like [Cloudflare Workers](https://developers.cloudflare.com/workers/), ensure you have sufficient CPU time budget (recommend >200ms per request) for encryption operations. ::: ## Basic Usage ```ts twoslash import { decrypt, encrypt } from '@orpc/server/helpers' const secret = 'your-encryption-key' const sensitiveData = 'user-email@example.com' const encryptedData = await encrypt(sensitiveData, secret) // 'Rq7wF8...' (base64url encoded, unreadable) const decryptedData = await decrypt(encryptedData, secret) // 'user-email@example.com' ``` :::info The `decrypt` helper accepts `undefined` or `null` as encrypted value and returns `undefined` for invalid inputs, enabling seamless handling of optional data. ::: --- # Form Data Helpers Source: https://orpc.dev/docs/helpers/form-data ## `parseFormData` Parses HTML form data using [bracket notation](/docs/openapi/bracket-notation) to deserialize complex nested objects and arrays. ```ts twoslash import { parseFormData } from '@orpc/openapi/helpers' const form = new FormData() form.append('name', 'John') form.append('user[email]', 'john@example.com') form.append('user[hobbies][]', 'reading') form.append('user[hobbies][]', 'gaming') const parsed = parseFormData(form) // Result: // { // name: 'John', // user: { // email: 'john@example.com', // hobbies: ['reading', 'gaming'] // } // } ``` ## `getIssueMessage` Extracts validation error messages from [standard schema](https://standardschema.dev/) issues using [bracket notation](/docs/openapi/bracket-notation) paths. ```ts twoslash import { getIssueMessage } from '@orpc/openapi/helpers' const error = { data: { issues: [ { path: ['user', 'email'], message: 'Invalid email format' } ] } } const emailError = getIssueMessage(error, 'user[email]') // Returns: 'Invalid email format' const tagError = getIssueMessage(error, 'user[tags][]') // Returns error message for any array item const anyError = getIssueMessage('anything', 'path') // Returns undefined if cannot find issue ``` :::warning The `getIssueMessage` utility works with any data type but requires validation errors to follow the [standard schema issue format](https://standardschema.dev/#the-specifications). It looks for issues in the `data.issues` property. If you [customize validation errors](/docs/recipes/validation-customization#custom-validation-errors), store them elsewhere, or modify the issue format, `getIssueMessage` may not work as expected. ::: ## Usage Example ```tsx import { getIssueMessage, parseFormData } from '@orpc/openapi/helpers' export function ContactForm() { const [error, setError] = useState() const handleSubmit = (form: FormData) => { try { const data = parseFormData(form) // Process structured data } catch (error) { setError(error) } } return (
{getIssueMessage(error, 'user[name]')} {getIssueMessage(error, 'user[emails][]')}
) } ``` --- # Publisher Helpers Source: https://orpc.dev/docs/helpers/publisher ## Installation ```package-install npm install @orpc/publisher@beta ``` ## Basic Usage The core concept is the `Publisher` interface, which defines a standard way to publish events and subscribe to them. You can create your own publisher or use one of the provided adapters for popular storage backends. The `publish` method accepts an event name and payload, while `subscribe` lets you listen to specific events using either callback or iterator styles. ```ts twoslash import { MemoryPublisher } from '@orpc/publisher/memory' import { os } from '@orpc/server' import * as z from 'zod' // ---cut--- const publisher = new MemoryPublisher<{ 'something-updated': { id: string } }>() const live = os .handler(async function* ({ input, signal, lastEventId }) { const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { // Handle payload here or yield directly to client yield payload } }) const publish = os .input(z.object({ id: z.string() })) .handler(async ({ input }) => { await publisher.publish('something-updated', { id: input.id }) }) ``` :::tip The publisher supports both static and dynamic event names. ```ts const publisher = new MemoryPublisher>() ``` ::: ## Adapters | Name | Resume Support | Adapter for | | ------------------- | -------------- | -------------------------------------------------------------------------------- | | `MemoryPublisher` | ✅ | In-memory storage | | `RedisPublisher` | ✅ | [Redis](https://github.com/redis/redis) | | `UpstashPublisher` | ✅ | [Upstash Redis](https://github.com/upstash/redis-js) | | `BunRedisPublisher` | ✅ | [Bun's Redis](https://bun.com/docs/runtime/redis) | | `DurablePublisher` | ✅ | [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) | ```ts memory import { MemoryPublisher } from '@orpc/publisher/memory' const publisher = new MemoryPublisher({ resume: { /** * Whether event resume support is enabled. * * When enabled, published events are temporarily stored so new * subscribers can resume from a previous position using `lastEventId`. * * @default false */ enabled: false, /** * How long (in seconds) to retain events for resume. * * Expired events are cleaned up lazily for performance reasons, so * some events may remain available slightly longer than this period. * * @default 300 (5 min) */ seconds: 300 } }) ``` ```ts redis import { createClient } from 'redis' import { RedisPublisher } from '@orpc/publisher/redis' const client = createClient({ url: 'redis://localhost:6379' }) // RedisPublisher lazily connects to Redis when needed. // You can still call `client.connect()` manually, but it is optional. await client.connect() const publisher = new RedisPublisher(client, { /** * Redis subscriber instance. * Pub/Sub takes over the connection, so a client with subscriptions * cannot execute commands and must use a dedicated connection. * * @default client.duplicate() */ subscriber: client.duplicate(), /** * The prefix to use for Redis keys. * * @default '' */ prefix: '', /** * Serializer for serialize and deserialize payloads. * * @default RPCJsonSerializer */ serializer: undefined, resume: { /** * Whether event resume support is enabled. * * When enabled, published events are temporarily stored so new * subscribers can resume from a previous position using `lastEventId`. * * @default false */ enabled: false, /** * How long (in seconds) to retain events for resume. * * Expired events are cleaned up lazily for performance reasons, so * some events may remain available slightly longer than this period. * * @default 300 (5 min) */ seconds: 300 } }) ``` ```ts upstash import { Redis } from '@upstash/redis' import { UpstashPublisher } from '@orpc/publisher/upstash' const redis = Redis.fromEnv() const publisher = new UpstashPublisher(redis, { /** * The prefix to use for Redis keys. * * @default '' */ prefix: '', /** * Serializer for serialize and deserialize payloads. * * @default RPCJsonSerializer */ serializer: undefined, resume: { /** * Whether event resume support is enabled. * * When enabled, published events are temporarily stored so new * subscribers can resume from a previous position using `lastEventId`. * * @default false */ enabled: false, /** * How long (in seconds) to retain events for resume. * * Expired events are cleaned up lazily for performance reasons, so * some events may remain available slightly longer than this period. * * @default 300 (5 min) */ seconds: 300 } }) ``` ```ts bun import { BunRedisPublisher } from '@orpc/bun' import { redis } from 'bun' const publisher = new BunRedisPublisher(redis, { /** * Redis subscriber instance. * Pub/Sub takes over the connection, so a client with subscriptions * cannot execute commands and must use a dedicated connection. * * @default redis.duplicate() (lazily created on first listen) */ subscriber: redis.duplicate(), /** * The prefix to use for Redis keys. * * @default '' */ prefix: '', /** * Serializer for serialize and deserialize payloads. * * @default RPCJsonSerializer */ serializer: undefined, resume: { /** * Whether event resume support is enabled. * * When enabled, published events are temporarily stored so new * subscribers can resume from a previous position using `lastEventId`. * * @default false */ enabled: false, /** * How long (in seconds) to retain events for resume. * * Expired events are cleaned up lazily for performance reasons, so * some events may remain available slightly longer than this period. * * @default 300 (5 min) */ seconds: 300 } }) ``` ```ts cloudflare import { DurablePublisher, DurablePublisherObject } from '@orpc/cloudflare' export class PublisherDO extends DurablePublisherObject { constructor(ctx: DurableObjectState, env: Env) { super(ctx, env, { resume: { /** * Whether event resume support is enabled. * * When enabled, published events are temporarily stored so new * subscribers can resume from a previous position using `lastEventId`. * * @default false */ enabled: false, /** * How long (in seconds) to retain events for resume. * * Expired events are cleaned up lazily for performance reasons, so * some events may remain available slightly longer than this period. * * @default 300 (5 min) */ seconds: 300, /** * Interval (in seconds) between cleanup checks for the Durable Object. * * At each interval, verify whether the Durable Object is inactive * (no active WebSocket connections and no stored events). If inactive, all * data is deleted to free resources; otherwise, another check is scheduled. * * @default 6 * 60 * 60 (6 hours) */ cleanupIntervalSeconds: 6 * 60 * 60, /** * Prefix for the resume storage table schema. * Used to avoid naming conflicts with other tables in the same Durable Object. * * @default 'orpc:' */ schemaPrefix: 'orpc:' } }) } } export default { async fetch(request, env) { const publisher = new DurablePublisher(env.PUBLISHER_DON, { /** * Prefix for events, to avoid naming conflicts with other publishers in the same Durable Object Namespace. * * @default '' */ prefix: '', /** * Serializer for serialize and deserialize payloads. * * @default RPCJsonSerializer */ serializer: undefined, /** * Custom function to get the Durable Object stub for publishing. * * @default ((namespace, event) => namespace.getByName(event)) */ getStubByName: (namespace, event) => namespace.getByName(event) }) }, } ``` ## Resume Missing Events Some adapters can resume events missed while a subscriber is offline. This feature is usually disabled by default, but you can enable it when creating the publisher. When enabled, the publisher automatically manages event ids and attempts to deliver events since the last event id provided by the subscriber. ```ts const publisher = new MemoryPublisher({ resume: { enabled: true, // Enable resuming missed events seconds: 60 * 5, // TTL in seconds } }) const iterator = publisher.subscribe('something-updated', { signal, lastEventId, // The publisher will attempt to deliver missed events since this event id }) ``` :::warning When resume is enabled, the publisher manages event ids automatically. This means: - Any event id provided during publishing is ignored - When subscribing, you must preserve and forward the event id when yielding custom payloads ```ts import { getEventMeta, withEventMeta } from '@orpc/server' const live = os .handler(async function* ({ input, signal, lastEventId }) { const iterator = publisher.subscribe('something-updated', { signal, lastEventId }) for await (const payload of iterator) { // Preserve event id when yielding custom payloads const id = getEventMeta(payload)?.id yield withEventMeta({ custom: 'value' }, { id }) } }) const publish = os .input(z.object({ id: z.string() })) .handler(async ({ input }) => { // The event id 'this-will-be-ignored' will be replaced by the publisher await publisher.publish( 'something-updated', withEventMeta({ id: input.id }, { id: 'this-will-be-ignored' }) ) }) ``` ::: ### Client Reconnection On the client, you can use the [Retry Plugin](/docs/plugins/retry), which automatically controls and passes `lastEventId` to the server when reconnecting. Alternatively, you can manage `lastEventId` manually: ```ts import { getEventMeta } from '@orpc/client' let lastEventId: string | undefined while (true) { try { const iterator = await client.live('input', { lastEventId }) for await (const payload of iterator) { lastEventId = getEventMeta(payload)?.id // Update lastEventId console.log(payload) } } catch { await new Promise(resolve => setTimeout(resolve, 1000)) // Wait 1 second before retrying } } ``` --- # Rate Limit Helpers Source: https://orpc.dev/docs/helpers/ratelimit ## Installation ```package-install npm install @orpc/ratelimit@beta ``` ## Basic Usage The core concept is the `RateLimiter` interface, which defines a standard way to check and enforce rate limits. You can create your own custom limiter or use one of the provided adapters for popular storage backends. The `limit` method accepts a key and an optional `weight` value, which defaults to `1`, so a single request can consume multiple points. ```ts twoslash import { MemoryRateLimiter } from '@orpc/ratelimit/memory' // ---cut--- import { ORPCError } from '@orpc/server' const limiter = new MemoryRateLimiter({ maxRequests: 5, window: 60000, }) const result = await limiter.limit('user:123', { weight: 2 }) if (!result.success) { throw new ORPCError('TOO_MANY_REQUESTS', { data: { limit: result.limit, remaining: result.remaining, reset: result.reset, }, }) } ``` ## Adapters The package includes adapters for multiple storage backends and runtimes. Each adapter might require `maxRequests` and `window` to configure the limit, along with adapter specific options. | Name | Blocking Mode | Adapter for | | ----------------------- | ------------- | --------------------------------------------------------------------------------------------------------- | | `MemoryRateLimiter` | ✅ | In-memory storage | | `RedisRateLimiter` | ✅ | [Redis](https://github.com/redis/redis) | | `UpstashRateLimiter` | ✅ | [Upstash Rate Limit](https://www.npmjs.com/package/@upstash/ratelimit) | | `BunRedisRateLimiter` | ✅ | [Bun's Redis](https://bun.com/docs/runtime/redis) | | `CloudflareRateLimiter` | ❌ | [Cloudflare's Rate Limiting](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/) | ```ts memory import { MemoryRateLimiter } from '@orpc/ratelimit/memory' const limiter = new MemoryRateLimiter({ /** * Maximum number of requests allowed within the window. */ maxRequests: 10, /** * The duration of the fixed window in milliseconds. */ window: 60000, blockingUntilReady: { /** * Block until the request may pass or timeout is reached. * * @default false */ enabled: false, /** * milliseconds */ timeout: 5000 }, }) ``` ```ts redis import { RedisRateLimiter } from '@orpc/ratelimit/redis' import { createClient } from 'redis' const client = createClient({ url: 'redis://localhost:6379' }) // RedisRateLimiter lazily connects to Redis when needed. // You can still call `client.connect()` manually, but it is optional. await client.connect() const limiter = new RedisRateLimiter(client, { /** * The prefix to use for Redis keys. * * @default '' */ prefix: '', /** * Maximum number of requests allowed within the window. */ maxRequests: 10, /** * The duration of the fixed window in milliseconds. */ window: 60000, blockingUntilReady: { /** * Block until the request may pass or timeout is reached. * * @default false */ enabled: false, /** * milliseconds */ timeout: 5000 }, }) ``` ````ts upstash import { Ratelimit } from '@upstash/ratelimit' import { Redis } from '@upstash/redis' import { UpstashRateLimiter } from '@orpc/ratelimit/upstash' const redis = Redis.fromEnv() const ratelimit = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '60 s'), prefix: 'orpc:', // Optional key prefix }) const limiter = new UpstashRateLimiter(ratelimit, { blockingUntilReady: { /** * Block until the request may pass or timeout is reached. * * @default false */ enabled: false, /** * milliseconds */ timeout: 5000 }, /** * For the MultiRegion setup we do some synchronizing in the background, after returning the current limit. * Or when analytics is enabled, we send the analytics asynchronously after returning the limit. * In most case you can simply ignore this. * * On Vercel Edge or Cloudflare workers, you might need `.bind` before assign: * ```ts * const ratelimiter = new UpstashRateLimiter(ratelimit, { * waitUntil: ctx.waitUntil.bind(ctx), * }) * ``` */ waitUntil: undefined }) ```` ```ts bun import { BunRedisRateLimiter } from '@orpc/bun' import { redis } from 'bun' const limiter = new BunRedisRateLimiter(redis, { /** * The prefix to use for Redis keys. * * @default '' */ prefix: '', /** * Maximum number of requests allowed within the window. */ maxRequests: 10, /** * The duration of the fixed window in milliseconds. */ window: 60000, blockingUntilReady: { /** * Block until the request may pass or timeout is reached. * * @default false */ enabled: false, /** * milliseconds */ timeout: 5000 }, }) ``` ```ts cloudflare import { CloudflareRateLimiter } from '@orpc/cloudflare' export default { async fetch(request, env) { const limiter = new CloudflareRateLimiter(env.MY_RATE_LIMITER, { /** * The prefix to use for cloudflare ratelimit. * * @default '' */ prefix: '' }) } } ``` ### Blocking Mode Some adapters support blocking mode, which waits until capacity becomes available instead of rejecting requests immediately. ```ts const limiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, blockingUntilReady: { enabled: true, // Disabled by default timeout: 5000, // Wait up to 5 seconds }, }) ``` ## Ratelimit Middleware The `ratelimit` helper creates middleware that enforces rate limits for [procedures](/docs/procedure). ```ts import { ratelimit, RateLimiter } from '@orpc/ratelimit' const procedure = os .$context<{ ratelimiter: RateLimiter }>() .input(z.object({ email: z.email() })) .use( ratelimit({ limiter: ({ context }) => context.ratelimiter, key: ({ context }, input) => `login:${input.email}`, weight: 1, // Optional weight for each request, default is 1 }), ) .handler(({ input }) => { return { success: true } }) const ratelimiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, }) const result = await call( procedure, { email: 'user@example.com' }, { context: { ratelimiter } } ) ``` :::info[Automatic Deduplication] When the same `limiter` and `key` combination is used multiple times in a single request chain, the `ratelimit` middleware performs the rate limit check only once. This behavior follows the [Dedupe Middleware](/docs/recipes/dedupe-middleware) recipe. To disable deduplication, set `dedupe: false`. ::: :::tip[Conditional Limiter] You can choose different limiters dynamically based on the request context: ```ts const premiumLimiter = new MemoryRateLimiter({ maxRequests: 100, window: 60000, }) const standardLimiter = new MemoryRateLimiter({ maxRequests: 10, window: 60000, }) const result = await call( procedure, { email: 'user@example.com' }, { context: { ratelimiter: isPremiumUser ? premiumLimiter : standardLimiter, }, }, ) ``` ::: ## Handler Plugin The `RateLimitHandlerPlugin` automatically adds HTTP rate limiting headers (`RateLimit-*` and `Retry-After`) to responses when used with [Ratelimit Middleware](#ratelimit-middleware). This lets clients inspect the current limit state and know when they can retry after hitting a limit. ```ts import { RateLimitHandlerPlugin } from '@orpc/ratelimit' const handler = new RPCHandler(router, { plugins: [ new RateLimitHandlerPlugin(), ], }) ``` :::info You can combine this plugin with [Retry After Plugin](/docs/plugins/retry-after) to enable automatic client-side retries based on server rate limiting headers. ::: :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: --- # Signing Helpers Source: https://orpc.dev/docs/helpers/signing :::info Signing is faster than [encryption](/docs/helpers/encryption) but users can view the original data. ::: ## Basic Usage ```ts twoslash import { getSignedValue, sign, unsign } from '@orpc/server/helpers' const secret = 'your-secret-key' const userData = 'user123' const signedValue = await sign(userData, secret) // 'user123.oneQsU0r5dvwQFHFEjjV1uOI_IR3gZfkYHij3TRauVA' // ↑ Original data is visible to users const verifiedValue = await unsign(signedValue, secret) // 'user123' // Extract value without verification const extractedValue = getSignedValue(signedValue) // 'user123' ``` :::info The `unsign` and `getSignedValue` helpers accept `undefined` or `null` as signed value and return `undefined` for invalid inputs, enabling seamless handling of optional data. ::: --- # AI SDK Integration Source: https://orpc.dev/docs/integrations/ai-sdk :::warning This documentation requires AI SDK v7.0.0 or later. For a refresher, review the [AI SDK documentation](https://ai-sdk.dev/docs). ::: ## Transport Use oRPC as the transport for AI SDK streams, sending them as either an [AsyncIteratorObject](/docs/async-iterator-object) or a [`ReadableStream`](/docs/binary-data#readablestreamuint8array). The examples below use the `AsyncIteratorObject` approach. ### Server Use `streamToAsyncIteratorObject` to convert AI SDK streams into [AsyncIteratorObject](/docs/async-iterator-object)s. ```ts import { os, streamToAsyncIteratorObject, type } from '@orpc/server' import { convertToModelMessages, streamText, toUIMessageStream, UIMessage } from 'ai' import { google } from '@ai-sdk/google' export const chat = os .input(type<{ chatId: string, messages: UIMessage[] }>()) .handler(async ({ input }) => { const result = streamText({ model: google('gemini-2.5-flash'), system: 'You are a helpful assistant.', messages: await convertToModelMessages(input.messages), }) return streamToAsyncIteratorObject( toUIMessageStream(result), ) }) ``` ### Client On the client side, convert the `AsyncIteratorObject` back to a stream using `asyncIteratorToUnproxiedDataStream` or `asyncIteratorToStream`. ```tsx import { useState } from 'react' import { useChat } from '@ai-sdk/react' import { asyncIteratorToUnproxiedDataStream } from '@orpc/client' import { client } from './client' export function Example() { const { messages, sendMessage, status } = useChat({ transport: { async sendMessages(options) { return asyncIteratorToUnproxiedDataStream(await client.chat({ chatId: options.chatId, messages: options.messages, }, { signal: options.abortSignal })) }, reconnectToStream(options) { throw new Error('Unsupported') }, }, }) const [input, setInput] = useState('') return ( <> {messages.map(message => (
{message.role === 'user' ? 'User: ' : 'AI: '} {message.parts.map((part, index) => part.type === 'text' ? {part.text} : null, )}
))}
{ e.preventDefault() if (input.trim()) { sendMessage({ text: input }) setInput('') } }} > setInput(e.target.value)} disabled={status !== 'ready'} placeholder="Say something..." />
) } ``` :::info The `reconnectToStream` function is not supported by default, which is fine for most use cases. If you need reconnection support, implement it similar to `sendMessages` with custom reconnection logic. ::: :::info Prefer `asyncIteratorToUnproxiedDataStream` over `asyncIteratorToStream`. AI SDK internally uses `structuredClone`, which doesn't support proxied data. oRPC may proxy events for [metadata](/docs/client/async-iterator-object#event-metadata), so unproxy before passing to AI SDK. ::: ## Tool Implementer Implements a [procedure contract](/docs/contract/procedure) as an [AI SDK Tool](https://ai-sdk.dev/docs/foundations/tools) by leveraging existing contract definitions. ```ts import { aiSdkTool, implementToolFactory } from '@orpc/ai-sdk' const getWeatherContract = oc .meta(aiSdkTool({ // Base AI SDK tool options description: 'Get the weather in a location', metadata: { source: 'weather-service' } })) .input(z.object({ location: z.string().describe('The location to get the weather for'), })) .output(z.object({ location: z.string().describe('The location the weather is for'), temperature: z.number().describe('The temperature in Celsius'), })) const implementTool = implementToolFactory() const getWeatherTool = implementTool(getWeatherContract, { execute: async ({ location }) => ({ location, temperature: 72 + Math.floor(Math.random() * 21) - 10, }), // ...add any additional AI SDK tool options or overrides here }) ``` :::info Standard [procedures](/docs/procedure) are also compatible with [procedure contracts](/docs/contract/procedure). ::: :::info The `aiSdkTool` [metadata](/docs/metadata) attaches base AI SDK tool options that every tool created from the procedure/contract inherits. If applied multiple times, later calls override matching keys from earlier ones. ::: ## Tool Factory Converts a [procedure](/docs/procedure) into an [AI SDK Tool](https://ai-sdk.dev/docs/foundations/tools) by leveraging existing procedure definitions. ```ts import { aiSdkTool, createToolFactory } from '@orpc/ai-sdk' import { os } from '@orpc/server' import { z } from 'zod' const getWeatherProcedure = os .meta(aiSdkTool({ // Base AI SDK tool options description: 'Get the weather in a location', metadata: { source: 'weather-service' } })) .input(z.object({ location: z.string().describe('The location to get the weather for'), })) .output(z.object({ location: z.string().describe('The location the weather is for'), temperature: z.number().describe('The temperature in Celsius'), })) .handler(async ({ input }) => ({ location: input.location, temperature: 72 + Math.floor(Math.random() * 21) - 10, })) const createTool = createToolFactory({ context: {}, // provide initial context if needed interceptors: [], // oRPC interceptors if needed }) const getWeatherTool = createTool(getWeatherProcedure, { // ...add any additional AI SDK tool options or overrides here }) ``` ### Streaming Tool Outputs When a procedure outputs an [AsyncIteratorObject](/docs/async-iterator-object), either validated with `asyncIteratorObject` or produced by an `async function*` handler, the resulting tool streams every event as a [preliminary tool result](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling#preliminary-tool-results): each event replaces the tool output in the UI, and the last event becomes the final tool result sent to the model. ```ts import { asyncIteratorObject, os } from '@orpc/server' const deployProcedure = os .input(z.object({ app: z.string() })) .output(asyncIteratorObject( z.object({ status: z.string(), url: z.string().optional().describe('Available once the deploy finishes'), }), )) .handler(async function* ({ input }) { yield { status: 'building' } yield { status: 'uploading' } yield { status: 'ready', url: `https://${input.app}.example.com` } }) const deployTool = createTool(deployProcedure) ``` --- # ArkType Integration Source: https://orpc.dev/docs/integrations/arktype :::info [ArkType](https://arktype.io/) implements [Standard Schema](/docs/integrations/standard-schema), so procedures accept ArkType types without any converter. The converter below is only needed by tools that consume JSON Schema, such as OpenAPI generation and Smart Coercion. ::: ## Installation ```package-install npm install @orpc/arktype@beta arktype ``` ## JSON Schema Converter `ArkTypeToJsonSchemaConverter` wraps [ArkType's built-in toJsonSchema](https://arktype.io/docs/type-api#tojsonschema) and adds support for additional types such as `bigint` and `Date`. Use it with tools such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator) and [Smart Coercion](/docs/plugins/smart-coercion). It accepts the same options as ArkType's `toJsonSchema`, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/arktype/src/converter.ts) and ArkType's [JSON Schema configuration docs](https://arktype.io/docs/configuration#tojsonschema) for implementation details. ```ts import { OpenAPIGenerator } from '@orpc/openapi' import { ArkTypeToJsonSchemaConverter } from '@orpc/arktype' const generator = new OpenAPIGenerator({ converters: [new ArkTypeToJsonSchemaConverter()], }) ``` :::tip Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable. ```ts const converter = new ArkTypeToJsonSchemaConverter({ cache: true }) ``` ::: ### Reusable Types A common pattern is defining reusable or recursive types using scopes. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](/docs/openapi/specification#hoisting-defs) into `components.schemas`. ```ts import { scope } from 'arktype' const types = scope({ Planet: { name: 'string', neighbors: 'Planet[]', }, }) const PlanetSchema = types.export().Planet ``` --- # Better Auth Integration Source: https://orpc.dev/docs/integrations/better-auth Use your [Better Auth](https://better-auth.com/) instance with oRPC's [context](/docs/context) and [middleware](/docs/middleware). No extra package is needed. :::tip You may need to forward Better Auth's [response headers](https://better-auth.com/docs/concepts/api#getting-headers), such as a refreshed session cookie. The [Response Headers Plugin](/docs/plugins/response-headers) can help unless you use the [Batch Plugin](/docs/plugins/batch). ::: ## Resolve the Session in Middleware The [Request Headers Plugin](/docs/plugins/request-headers) exposes request headers as `context.reqHeaders`. The middleware loads the session from them and rejects unauthenticated calls. Public procedures use the base directly. Each protected call performs its own lookup, including every sub-request of a [batch](/docs/plugins/batch). ```ts import type { RequestHeadersHandlerPluginContext } from '@orpc/server/plugins' import { ORPCError, os } from '@orpc/server' interface ServerContext extends RequestHeadersHandlerPluginContext {} const base = os.$context() const requireSession = base.middleware(async ({ context, next }) => { const session = await auth.api.getSession({ headers: context.reqHeaders ?? new Headers(), }) if (!session) { throw new ORPCError('UNAUTHORIZED') } return next({ context: { session } }) }) const protectedProcedure = base.use(requireSession) const router = { ping: base.handler(() => ({ message: 'pong' })), me: protectedProcedure.handler(({ context }) => ({ id: context.session.user.id, name: context.session.user.name, })), } ``` `context.session` is Better Auth's full result with `user` and `session`. Its type is inferred from your auth instance, so additional fields stay available. Only a missing session becomes `UNAUTHORIZED`. Other errors from `getSession` propagate to oRPC's [error handling](/docs/error-handling). `reqHeaders` is `undefined` without the plugin, such as in [server-side calls](/docs/client/server-side). The empty `Headers` fallback carries no session cookie, so `getSession` returns `null` and protected calls return `UNAUTHORIZED`. Pass `reqHeaders` in the initial context to authenticate such calls. ## Lazily Load and Share the Session If your server resolves the session itself, pass a lazy getter into the initial context instead of the session. The lookup runs at most once per request and only when a procedure asks for it. This includes [batch](/docs/plugins/batch) requests, where every sub-request shares the getter. The same getter can also serve the rest of your request handling. ```ts import { ORPCError, os } from '@orpc/server' import { RPCHandler } from '@orpc/server/fetch' type Session = Awaited> function once(fn: () => Promise): () => Promise { let promise: Promise | undefined return () => { promise ??= fn() return promise } } const base = os.$context<{ getSession: () => Promise }>() const requireSession = base.middleware(async ({ context, next }) => { const session = await context.getSession() if (!session) { throw new ORPCError('UNAUTHORIZED') } return next({ context: { session } }) }) const protectedProcedure = base.use(requireSession) const router = { greeting: base.handler(async ({ context }) => { const session = await context.getSession() return { message: `Hello, ${session?.user.name ?? 'guest'}` } }), me: protectedProcedure.handler(({ context }) => ({ id: context.session.user.id, name: context.session.user.name, })), } const handler = new RPCHandler(router) export async function fetch(request: Request): Promise { const getSession = once(() => auth.api.getSession({ headers: request.headers })) const { matched, response } = await handler.handle(request, { prefix: '/rpc', context: { getSession }, }) return matched ? response : new Response('Not Found', { status: 404 }) } ``` --- # Cloudflare Workers Traces Integration Source: https://orpc.dev/docs/integrations/cloudflare-traces :::warning This guide assumes familiarity with [Cloudflare Workers Traces](https://developers.cloudflare.com/workers/observability/traces/). Review the official documentation if needed. ::: ## Installation ```package-install npm install @orpc/cloudflare@beta ``` ## Setup Enable traces in your Wrangler configuration, then register `CloudflareTracer` once at module scope. oRPC then records the same spans as the [OpenTelemetry integration](/docs/integrations/opentelemetry), nested under the spans Workers create automatically. ```jsonc title="wrangler.jsonc" { "observability": { "traces": { "enabled": true } } } ``` ```ts import { experimental_CloudflareTracer as CloudflareTracer } from '@orpc/cloudflare' new CloudflareTracer().enable() ``` :::info `CloudflareTracer` covers both handlers and links, so a Worker that calls another oRPC server also records client spans. oRPC uses a single tracer, so do not enable `ORPCInstrumentation` alongside it. ::: :::tip During local development, [Local Explorer](https://developers.cloudflare.com/workers/local-development/local-explorer/) shows these spans without deploying: press `e` in `wrangler dev`, or open `/cdn-cgi/local/explorer` on the Vite dev server. ::: ## Middleware Span oRPC creates a span for each [middleware](/docs/middleware) execution. Use `tracing.getActiveSpan()` to add attributes to it: ```ts import { tracing } from 'cloudflare:workers' export const someMiddleware = os.middleware(async (ctx, next) => { tracing.getActiveSpan()?.setAttribute('someAttribute', 'someValue') return next() }) Object.defineProperty(someMiddleware, 'name', { value: 'someName', }) ``` :::tip Define the `name` property on your middleware to improve span naming and make traces easier to read. ::: ## Limitations Workers Traces still lack some APIs oRPC relies on, so: - Request spans are not renamed to the procedure path. The path is still recorded in the `rpc.method` attribute. - Streamed inputs and outputs record no `yielded` and `enqueued` events. - Trace context propagation is not configurable by oRPC. --- # Effect Integration Source: https://orpc.dev/docs/integrations/effect :::warning This guide assumes familiarity with [Effect](https://effect.website/). Review the official documentation if needed. ::: ## Installation ```package-install npm install @orpc/experimental-effect@beta effect@beta ``` ## Effectful Handlers `handlerGen` allows you to write effectful handlers using generator functions. Inside the generator, you can yield Effect operations, and `handlerGen` will handle the execution and error handling for you. ```ts twoslash import { os } from '@orpc/server' // ---cut--- import { handlerGen } from '@orpc/experimental-effect' import { Effect } from 'effect' const procedure = os.handler(handlerGen(function* ({ input, context }) { // You can use Effect's features here, such as concurrency, error handling, etc. const result = yield* Effect.promise(() => Promise.resolve(5)) return result })) ``` ### `.effect` extension Import `@orpc/experimental-effect/extensions/effect` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds an `.effect` method to the builder so you can write effectful handlers directly. ```ts usage const procedure = base.effect(function* ({ input, context }) { // You can use Effect's features here, such as concurrency, error handling, etc. const result = yield* Effect.promise(() => Promise.resolve(5)) return result }) ``` ```ts setup import '@orpc/experimental-effect/extensions/effect' import { os } from '@orpc/server' export const base = os ``` ### Effect Services You can provide Effect services through the oRPC context in a typesafe way with `WithEffectContext` and `effect/context`: ```ts twoslash import { call, os } from '@orpc/server' // ---cut--- import { handlerGen, WithEffectContext } from '@orpc/experimental-effect' import { Context, Effect } from 'effect' class Random extends Context.Service< Random, { readonly next: Effect.Effect } >()('MyRandomService') {} interface ServerContext extends WithEffectContext {} const procedure = os .$context() .handler(handlerGen(function* ({ input, context }) { const random = yield* Random const result = yield* random.next return result })) const random = await call(procedure, undefined, { context: { 'effect/context': Context.empty().pipe( Context.add(Random, { next: Effect.succeed(Math.random()), }), ) } }) ``` :::info You can also extend the Effect context with [middleware](/docs/middleware): ```ts const procedure = os .$context() .use(({ context, next }) => { return next({ context: { 'effect/context': context['effect/context'].pipe( Context.add(AdditionService, {}), ) } }) }) .handler(handlerGen(function* ({ input, context }) { const additionService = yield* AdditionService })) ``` ::: ### Error Handling This integration preserves the original error whenever possible. If you call `Effect.fail(error)`, the error is forwarded to [middleware](/docs/middleware) and interceptors, just like a regular thrown error. To customize this behavior, wrap the effect before execution using `effect/wrap` in the context: ```ts import { Context, Effect } from 'effect' interface ServerContext extends WithEffectContext {} export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { context: { 'effect/context': Context.empty(), 'effect/wrap': (effect, opts) => effect.pipe( Effect.catchCause((cause) => { }) ), } }) if (matched) { return response } return new Response('Not Found', { status: 404 }) } ``` :::info For app level error handling, we recommend [middleware](/docs/middleware) or interceptors. ::: ### Typesafe Errors An `ORPCError` that fails the effect, such as `yield* Effect.fail(new ORPCError(...))`, is thrown from the handler exactly like a thrown `ORPCError` in a regular handler. Define your errors with `.errors` and fail with `errors.X(...)` to make them [typesafe](/docs/error-handling#typesafe-errors) on the client: ```ts const procedure = os .errors({ NOT_FOUND: { message: 'The resource you are looking for does not exist', }, }) .handler(handlerGen(function* ({ errors }) { if (resourceNotFound) { yield* Effect.fail(errors.NOT_FOUND()) } return 'Success' })) const [error, result] = await safe(call(procedure)) if (isDefinedError(error)) { // typesafe error handling } ``` ### Catching ORPCErrors Use `catchORPCError` to recover from every `ORPCError` failure in the error channel of an effect, or `catchORPCErrorCode` and `catchORPCErrorCodes` to recover from specific codes only. Recovered errors are excluded from the resulting effect, and other failures re-fail with their original cause: ```ts import { catchORPCError, catchORPCErrorCode, catchORPCErrorCodes } from '@orpc/experimental-effect' import { Effect } from 'effect' const recovered = program.pipe( catchORPCError(error => Effect.succeed(`caught ${error.code}`)), ) const fallback = program.pipe( catchORPCErrorCode('NOT_FOUND', error => Effect.succeed(error.data.id)), ) const handled = program.pipe( catchORPCErrorCodes({ NOT_FOUND: error => Effect.succeed(error.data.id), CONFLICT: error => Effect.succeed(error.message), }), ) ``` :::info All utilities support data-first `catchORPCError(program, handler)` and data-last `program.pipe(catchORPCError(handler))` styles. ::: ## Effectful Middleware `middlewareGen` allows you to write effectful middleware using generator functions. Inside the generator, you can yield Effect operations and `yield* next()` to continue the chain. Downstream failures land in the error channel of `next`, so you can recover from them with Effect. ```ts twoslash import { os } from '@orpc/server' // ---cut--- import { middlewareGen } from '@orpc/experimental-effect' import { Effect } from 'effect' const procedure = os .$context<{ auth: boolean }>() .use(middlewareGen(function* ({ context, next }) { const startedAt = yield* Effect.sync(() => Date.now()) return yield* next({ context: { startedAt } }) })) .handler(({ context }) => context.startedAt) ``` ## Client Calls `createEffectClient` wraps any oRPC client, whether [server-side](/docs/client/server-side) or [client-side](/docs/client/client-side), so every procedure returns a lazy Effect instead of a promise, ready to `yield*` inside Effect generators. The output becomes the success value, and errors land in the error channel with their original types preserved, ready for utilities like `catchORPCErrorCodes`: ```ts import { catchORPCErrorCodes, createEffectClient } from '@orpc/experimental-effect' import { Effect } from 'effect' const effectClient = createEffectClient(client) const program = Effect.gen(function* () { const planet = yield* effectClient.planet.find({ id: 1 }) return planet.name }).pipe( catchORPCErrorCodes({ NOT_FOUND: error => Effect.succeed('unknown'), }), ) ``` You can also combine `Effect.catchIf` with [isDefinedError](/docs/client/error-handling#using-safe-and-isdefinederror) to recover from every defined error in a typesafe way: ```ts import { isDefinedError } from '@orpc/client' import { Effect } from 'effect' const recovered = effectClient.planet.find({ id: 1 }).pipe( Effect.catchIf(isDefinedError, (error) => { // error is fully typed here return Effect.succeed(null) }), ) ``` :::info The effects are lazy: the client is invoked each time the effect runs, so they work naturally with `Effect.retry`. Interrupting the effect aborts the underlying call. ::: ## Effect Schema oRPC natively supports [Standard Schema](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec), and [Effect Schema](https://effect.website/docs/schema/introduction/) implements that spec through [Schema.toStandardSchemaV1](https://effect.website/docs/schema/standard-schema/): ```ts import { Schema } from 'effect' const procedure = os .input(Schema.toStandardSchemaV1(Schema.Struct({ name: Schema.String }))) .handler(handlerGen(function* ({ input, context }) { return `Hello ${input.name}!` })) ``` ### `.input` and `.output` Extensions Import `@orpc/experimental-effect/extensions/input-output` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This lets you define `.input` and `.output` directly with Effect Schema: ```ts usage const procedure = base .input(Schema.Struct({ name: Schema.String })) .output(Schema.Struct({ greeting: Schema.String })) .handler(handlerGen(function* ({ input, context }) { return { greeting: `Hello ${input.name}!` } })) ``` ```ts setup import '@orpc/experimental-effect/extensions/input-output' import { os } from '@orpc/server' export const base = os ``` :::info You can also use these extensions with the [contract builder](/docs/contract/procedure). ::: ### JSON Schema Converter This integration also provides `EffectSchemaToJsonSchemaConverter`, built on top of [Effect Schema to JSON Schema](https://effect.website/docs/schema/json-schema/). You can use it with tools such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator): ```ts import { EffectSchemaToJsonSchemaConverter } from '@orpc/experimental-effect' const generator = new OpenAPIGenerator({ converters: [new EffectSchemaToJsonSchemaConverter()], }) ``` ## OpenTelemetry Integration First, set up the [oRPC OpenTelemetry integration](/docs/integrations/opentelemetry). Then instrument your Effect to work seamlessly with OpenTelemetry by providing `TracingLive` through `effect/wrap` in the context. This makes Effect tracing equivalent to OpenTelemetry tracing: ```ts import { Resource, Tracer } from '@effect/opentelemetry' import { Context, Effect, Layer } from 'effect' interface ServerContext extends WithEffectContext {} const TracingLive = Tracer.layerGlobal.pipe( Layer.provide(Resource.layerFromEnv()), ) export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { context: { 'effect/context': Context.empty(), 'effect/wrap': (effect, opts) => effect.pipe(Effect.provide(TracingLive)), } }) if (matched) { return response } return new Response('Not Found', { status: 404 }) } ``` --- # Evlog Integration Source: https://orpc.dev/docs/integrations/evlog :::warning This guide assumes familiarity with [Evlog](https://evlog.dev/). Review the official documentation if needed. ::: ## Installation ```package-install npm install @orpc/evlog@beta evlog@beta ``` ## Setup Use `EvlogHandlerPlugin` to instrument your handler with structured logs, request tracking, and error monitoring. ```ts twoslash import { RPCHandler } from '@orpc/server/fetch' import { router } from './shared/planet' // ---cut--- import { EvlogHandlerPlugin } from '@orpc/evlog' const handler = new RPCHandler(router, { plugins: [ new EvlogHandlerPlugin({ drain: undefined, // <- custom Evlog drain (optional) plugins: [], // <- additional Evlog plugins (optional) logAbort: true, // <- log when requests are aborted (disabled by default) }), ], }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: ## Error Logging Levels Errors thrown from your procedures set the wide event's level to match their intent: `info` for abort errors, `warn` for `ORPCError` instances since they represent deliberate rejections, and `error` for everything else, including `ORPCError` with the `INTERNAL_SERVER_ERROR` code. Use the `procedureErrorLevel` option to customize this behavior: ```ts const plugin = new EvlogHandlerPlugin({ procedureErrorLevel: (error, level) => { if (level === 'error' && error instanceof ExpectedError) { return 'warn' } return level }, }) ``` ## Using the Logger in Your Code This plugin supports using [AsyncLocalStorage](https://nodejs.org/api/async_context.html#class-asynclocalstorage) to access the logger throughout a request and enrich the final [wide event](https://www.evlog.dev/learn/wide-events#uselogger-retrieving-the-request-logger). It is the most convenient way to use Evlog's full feature set. If your runtime does not support AsyncLocalStorage, you can still [access the logger from the context](#without-asynclocalstorage). ```ts title="business logic" import { createLoggerStorage } from '@orpc/evlog/node' /** * Pass `storage` to the plugin configuration. * Call `useLogger` inside a procedure to access the request logger. */ export const { storage, useLogger } = createLoggerStorage() const procedure = os .handler(async () => { const logger = useLogger() // [!code highlight] logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] await logger.fork('child-procedure', () => { const logger = useLogger() // [!code highlight] }) return { success: true } }) ``` ```ts title="handler setup" const handler = new RPCHandler(router, { plugins: [ new EvlogHandlerPlugin({ storage, // <- pass the storage to the plugin }), ], }) ``` ### Without AsyncLocalStorage If you do not want to use AsyncLocalStorage, or your runtime does not support it, you can still read the logger from the context. ```ts import { getLogger, LoggerContext } from '@orpc/evlog' interface ServerContext extends LoggerContext {} // [!code highlight] const procedure = os .$context() .handler(({ context }) => { const logger = getLogger(context) // [!code highlight] logger?.set({ user: { id: 123, name: 'John Doe' } }) // [!code highlight] return { success: true } }) ``` --- # Hibernation Integration Source: https://orpc.dev/docs/integrations/hibernation ## Installation ```package-install npm install @orpc/hibernation@beta ``` ## Setup ```ts import { HibernationHandlerPlugin } from '@orpc/hibernation' const handler = new RPCHandler(router, { plugins: [ new HibernationHandlerPlugin(), ], }) ``` :::warning When combined with the [Batch Plugin](/docs/plugins/batch), make sure procedures that return a `HibernationAsyncIteratorClass` are excluded from batching (e.g. via the batch link plugin's `filter` option), because hibernation cannot work through batched responses. ::: ## Usage The plugin provides `HibernationAsyncIteratorClass` and `encodeHibernationRPCEvent` to help you return an [Async Iterator Object](/docs/async-iterator-object) that utilizes the Hibernation APIs. 1. Return a `HibernationAsyncIteratorClass` from your handler ```ts import { HibernationAsyncIteratorClass } from '@orpc/hibernation' const base = os.$context<{ ws: WebSocket }>() export const onMessage = base.handler(async ({ context }) => { return new HibernationAsyncIteratorClass<{ message: string }>((id) => { // Save the ID. You'll need it to send events later. context.ws.serializeAttachment({ id }) }) }) ``` 2. Send events to clients with `encodeHibernationRPCEvent` ```ts import { encodeHibernationRPCEvent } from '@orpc/hibernation' import * as z from 'zod' const base = os.$context<{ getWebSockets: () => WebSocket[] }>() export const sendMessage = base .input(z.object({ message: z.string() })) .handler(async ({ input, context }) => { const websockets = context.getWebSockets() for (const ws of websockets) { const { id } = ws.deserializeAttachment() // yield an event to all clients ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { // override the default RPC serializer if needed serializer: new RPCSerializer(), })) // return an event and stop the iterator ws.send(await encodeHibernationRPCEvent(id, { message: input.message }, { event: 'close' })) // throw an error and stop the iterator ws.send(await encodeHibernationRPCEvent(id, new ORPCError('INTERNAL_SERVER_ERROR'), { event: 'error' })) } }) ``` This example shows how to build a chat room with [Cloudflare Durable Objects](https://developers.cloudflare.com/durable-objects/) and [WebSocket Hibernation](https://developers.cloudflare.com/durable-objects/examples/websocket-hibernation-server/). Everyone connected to the same Durable Object can exchange messages. You can try a working version in the Cloudflare Playground, see [Playgrounds](/docs/playgrounds). ```ts title="Durable Object" import { RPCHandler } from '@orpc/server/websocket' import { encodeHibernationRPCEvent, HibernationAsyncIteratorClass, HibernationHandlerPlugin, } from '@orpc/hibernation' import { onError, os } from '@orpc/server' import { DurableObject } from 'cloudflare:workers' import * as z from 'zod' const base = os.$context<{ handler: RPCHandler ws: WebSocket getWebsockets: () => WebSocket[] }>() export const router = { send: base.input(z.object({ message: z.string() })).handler(async ({ input, context }) => { const websockets = context.getWebsockets() for (const ws of websockets) { const data = ws.deserializeAttachment() if (typeof data !== 'object' || data === null) { continue } const { id } = data ws.send(await encodeHibernationRPCEvent(id, input.message)) } }), onMessage: base.handler(async ({ context }) => { return new HibernationAsyncIteratorClass((id) => { context.ws.serializeAttachment({ id }) }) }), } const handler = new RPCHandler(router, { interceptors: [ onError((error) => { console.error(error) }), ], plugins: [ new HibernationHandlerPlugin(), ], }) export class ChatRoom extends DurableObject { async fetch(): Promise { const { '0': client, '1': server } = new WebSocketPair() this.ctx.acceptWebSocket(server) return new Response(null, { status: 101, webSocket: client, }) } async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { await handler.message(ws, message, { context: { handler, ws, getWebsockets: () => this.ctx.getWebSockets(), }, }) } async webSocketClose(ws: WebSocket): Promise { await handler.close(ws) } } ``` ```ts Client import { RPCLink } from '@orpc/client/websocket' import { createORPCClient } from '@orpc/client' import type { router } from '../../worker/dos/chat-room' import type { RouterClient } from '@orpc/server' const websocket = new WebSocket(`${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/chat-room`) websocket.addEventListener('error', (event) => { console.error(event) }) const link = new RPCLink({ connect: () => websocket, }) export const chatRoomClient: RouterClient = createORPCClient(link) ``` ```tsx Component import { useEffect, useState } from 'react' import { chatRoomClient } from '../lib/chat-room' export function ChatRoom() { const [messages, setMessages] = useState([]) useEffect(() => { const controller = new AbortController() void (async () => { for await (const message of await chatRoomClient.onMessage(undefined, { signal: controller.signal })) { setMessages(messages => [...messages, message]) } })() return () => { controller.abort() } }, []) const sendMessage = async (e: React.FormEvent) => { e.preventDefault() const form = new FormData(e.target as HTMLFormElement) const message = form.get('message') as string await chatRoomClient.send({ message }) } return (

Chat Room

Open multiple tabs to chat together

    {messages.map((message, index) => (
  • {message}
  • ))}
) } ```
--- # MSW Integration Source: https://orpc.dev/docs/integrations/msw :::warning This guide assumes you are already familiar with [MSW](https://mswjs.io/). If you need a refresher, review the official MSW documentation before continuing. ::: ## Installation ```package-install npm install @orpc/experimental-msw@beta ``` ## Setup Create MSW utils from a [router contract](/docs/contract/router) or an implemented [router](/docs/router) (convert [lazy routers](/docs/router#lazy-router) with [`unlazyRouter`](/docs/contract/router#router-to-contract) first). The `handler` option creates the fetch handler that serves each mock. Configure it like your production handler, so serialization, validation, and error envelopes behave exactly like your real server. ```ts import { createHTTPUtils } from '@orpc/experimental-msw' import { RPCHandler } from '@orpc/server/fetch' export const mock = createHTTPUtils(contract, { prefix: '/rpc', handler: router => new RPCHandler(router), }) ``` Set `prefix` to the prefix your link sends requests to. Any origin matches by default; narrow it with the `origin` option, which supports MSW wildcards. :::tip Any protocol works: pair the [RPCLink](/docs/rpc/link) with a [RPCHandler](/docs/rpc/handler), or the [OpenAPILink](/docs/openapi/link) with an [OpenAPIHandler](/docs/openapi/handler). ::: ## Mocking Procedures The `.handler` method creates an MSW request handler that resolves a procedure. The input your mock receives and the output it returns are validated and serialized by the created fetch handler, exactly like on a real server. ```ts import { setupServer } from 'msw/node' const server = setupServer( mock.planet.list.handler(({ input }) => [ { id: 1, name: 'Earth' }, ]), ) server.listen() ``` To access request details, such as the raw `request`, expose them through the [`context` option](#advanced-configuration). :::info [AsyncIteratorObject](/docs/async-iterator-object) outputs work too: return an async generator and the client receives a streamed response. ::: ## Mocking Errors The `.error` method creates an MSW request handler that rejects a procedure with one of its [defined errors](/docs/contract/procedure#typesafe-errors), serialized exactly like a server-thrown error. For dynamic or arbitrary errors, use `.handler` and throw the `errors` constructors or any [`ORPCError`](/docs/error-handling#orpcerror-class): ```ts import { ORPCError } from '@orpc/client' const handlers = [ mock.planet.find.error('NOT_FOUND', { data: { id: 123 } }), mock.planet.update.handler(({ input, errors }) => { throw errors.CONFLICT({ data: { id: input.id } }) }), mock.planet.delete.handler(() => { throw new ORPCError('SERVICE_UNAVAILABLE') }), ] ``` ## Mocking Loading States The `.loading` method creates an MSW request handler that never resolves, useful for testing loading states, for example in [Storybook](https://storybook.js.org/docs/writing-stories/mocking-data-and-modules/mocking-network-requests) stories: ```ts export const Loading: Story = { parameters: { msw: { handlers: [mock.planet.list.loading()], }, }, } ``` ## Passthrough The `.passthrough` method creates an MSW request handler that performs matching requests against the real server as-is, useful to exempt specific procedures from mocking, for example while [onUnhandledRequest](https://mswjs.io/docs/api/setup-server/listen#onunhandledrequest) treats everything else as an error: ```ts const handlers = [ mock.planet.list.handler(() => []), mock.planet.find.passthrough(), // hits the real server ] ``` ## Advanced Configuration All handler behavior is configured through the `handler` option, so mocks can mirror your production setup exactly, such as plugins, a custom serializer, or `allowMethods` if your client sends [GET requests](/docs/rpc/handler#supported-http-methods) over the RPC protocol: ```ts import { RPCHandler } from '@orpc/server/fetch' import { ResponseHeadersHandlerPlugin } from '@orpc/server/plugins' const mock = createHTTPUtils(contract, { prefix: '/rpc', handler: router => new RPCHandler(router, { plugins: [new ResponseHeadersHandlerPlugin()], }), }) ``` The `context` option controls the [context](/docs/context) passed to the created handler on each request, and mock handlers receive it as `context`, enabling context-driven behaviors such as the [Response Headers Plugin](/docs/plugins/response-headers). ```ts import { ResponseHeadersHandlerPlugin, type ResponseHeadersHandlerPluginContext } from '@orpc/server/plugins' interface MockServerContext extends ResponseHeadersHandlerPluginContext { reqHeaders: Headers } const mock = createHTTPUtils(contract, { context: (info): MockServerContext => ({ reqHeaders: info.request.headers }), handler: router => new RPCHandler(router, { plugins: [new ResponseHeadersHandlerPlugin()], }), }) const handlers = [ mock.planet.list.handler(({ context }) => { const locale = context.reqHeaders.get('accept-language') ?? 'en' context.resHeaders?.set('content-language', locale) return [] }), ] ``` You can also disable input or output validation of the mocked data: ```ts const mock = createHTTPUtils(contract, { handler: router => new RPCHandler(router), disableInputValidation: true, disableOutputValidation: true, }) ``` Each mock serves a router containing only the procedure being mocked. Requests the created handler does not match simply fall through to other MSW handlers. ## Limitations Requests sent through the [Batch Requests Plugin](/docs/plugins/batch) cannot be mocked. Each mock serves a router containing only its own procedure, so even a `handler` configured with the batch plugin cannot resolve the other procedures bundled into the same HTTP request. Disable batching when mocking with MSW. --- # Implement oRPC contract with NestJS Source: https://orpc.dev/docs/integrations/nest ## Installation ```package-install npm install @orpc/nest@beta ``` ## Requirements oRPC is an ESM-only library, but NestJS versions below v12 do not natively support ESM. You might need to configure your project for ESM and use a Node.js version that supports `require()` for ESM modules (Node.js 22+ is recommended). The following configuration is recommended: ```json tsconfig.json { "compilerOptions": { "module": "NodeNext", // <- recommended "strict": true // <- recommended // ... other options } } ``` ## Define Your Contract Before implementation, define your [contract](/docs/contract/procedure) as usual, including [routing](/docs/openapi/routing). There is no special requirement, except that each contract must define an `openapi.path` meta. ```ts import { oc } from '@orpc/contract' import { openapi, populateRouterContractOpenAPIPaths } from '@orpc/openapi' const example = oc.meta(openapi({ path: '/example' // [!code highlight] })) // or using the `populateRouterContractOpenAPIPaths` helper to // automatically populate OpenAPI paths for all contracts const contract = populateRouterContractOpenAPIPaths({ example }) ``` ## Implement Your Contract To implement your contract in NestJS, use the `@Implement` decorator and the `implement` function. The `@Implement` very similar to NestJS built-in HTTP method decorators (e.g., `@Get`, `@Post`) and can be used to implement either a single procedure contract or an [router contract](/docs/contract/router) or combine with other NestJS decorators. ```ts import { Implement } from '@orpc/nest' import { implement, ORPCError } from '@orpc/server' @Controller() export class PlanetController { /** * Implement a procedure contract */ @Implement(contract.planet.list) list() { return implement(contract.planet.list).handler(({ input }) => { // Implement logic here }) } /** * Implement a router contract */ @Implement(contract.planet) planet() { return { list: implement(contract.planet.list).handler(({ input }) => { // Implement logic here }), find: implement(contract.planet.find).handler(({ input }) => { // Implement logic here }), create: implement(contract.planet.create).handler(({ input }) => { // Implement logic here }), } } // other handlers... } ``` :::info When you use the `@Implement` decorator with a router contract, under the hood it creates a corresponding NestJS method for each procedure contract. All decorators applied to the original method are reflected on these methods. ::: ## Error Handling By default, errors thrown in implemented procedures are caught and handled by oRPC, which then rethrows a generic `HttpException` to NestJS. If you want NestJS to catch the original error instead of `HttpException`, use the [Rethrow Plugin](/docs/plugins/rethrow) to bypass oRPC error handling and let NestJS handle the error directly. :::tip Learn how to customize input and output validation errors in [Validation Errors](/docs/recipes/validation-customization#custom-validation-errors) and the [ORPCModule](#configuration) section. ::: ## Body Parser oRPC uses bodies parsed by NestJS when available, and falls back to its own parser otherwise. In some cases, you may want to disable the NestJS body parser so oRPC can handle parsing directly: - NestJS `urlencoded` parsing does not support [Bracket Notation](/docs/openapi/bracket-notation). - File uploads with common content types like `application/json` may not be parsed as `File` instances. ```ts import { NestFactory } from '@nestjs/core' import { AppModule } from './app.module' async function bootstrap() { const app = await NestFactory.create(AppModule, { bodyParser: false, // [!code highlight] }) await app.listen(process.env.PORT ?? 3000) } ``` ## Configuration Configure `@orpc/nest` by importing `ORPCModule` into your NestJS module. It supports the same options as the [OpenAPI Handler](/docs/openapi/handler), except for options that are unrelated to NestJS and options that are specific to NestJS. ```ts Static import { onError } from '@orpc/server' import { ORPCModule } from '@orpc/nest' @Module({ imports: [ ORPCModule.forRoot({ interceptors: [ onError((error) => { console.error(error) }), ], }), ], }) export class AppModule {} ``` ```ts title="Dynamic with Dependency Injection" import { onError } from '@orpc/server' import { ORPCModule } from '@orpc/nest' @Module({ imports: [ ORPCModule.forRootAsync({ inject: [YourLoggerService], useFactory: (logger: YourLoggerService) => ({ interceptors: [ onError((error) => { logger.error(error) }), ], }), }), ], }) export class AppModule {} ``` ### Initial Context To define [initial context](/docs/context#initial-context) for use in oRPC scopes, extend the `DefaultInitialContext` interface and provide `context` through `ORPCModule`. ```ts import { ExecutionContext } from '@nestjs/common' declare module '@orpc/server' { /** * Extend the context interface to enable typesafe access across oRPC scopes */ interface DefaultInitialContext { request: Request } } @Module({ imports: [ ORPCModule.forRoot({ /** * Can be a static value or an async function that * receives the ExecutionContext on each request */ context: (ctx: ExecutionContext) => { const request = ctx.switchToHttp().getRequest() as Request return { request } }, }), ], }) export class AppModule {} ``` ### Plugins Most handler plugins also work in NestJS, for example [Request Headers](/docs/plugins/request-headers), [Response Headers](/docs/plugins/response-headers), [Rethrow](/docs/plugins/rethrow), and [Smart Coercion](/docs/plugins/smart-coercion). ```ts @Module({ imports: [ ORPCModule.forRoot({ plugins: [ new RethrowHandlerPlugin({ // Bypass oRPC error handling and let NestJS handle the error instead filter: error => !(error instanceof ORPCError) }), ], }), ], }) export class AppModule {} ``` :::warning Procedures run only when a matching NestJS controller method is called. If no route matches (404), neither the procedure nor its plugins run. As a result, plugins like [OpenAPI Reference](/docs/plugins/openapi-reference) may not work as expected, since NestJS can respond with 404 before the procedure runs. ::: ### Event Stream Options Configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client using the `toNestResponse.eventStream` options. ```ts @Module({ imports: [ ORPCModule.forRoot({ toNestResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }), ], }) export class AppModule {} ``` ### `toNestStandardLazyRequest` option By default, `@orpc/nest` supports the Express and Fastify adapters. If you use another adapter, you may need to customize how a NestJS request is converted into a standard request (including additional params). For details, see [Standard Server](https://github.com/middleapi/standard-server#request-and-response-types). ```ts import { NestStandardLazyRequest } from '@orpc/nest' import { toStandardLazyRequest } from '@standard-server/fetch' @Module({ imports: [ ORPCModule.forRoot({ toNestStandardLazyRequest: (req, res) => { // example Hono platform support const standardRequest: NestStandardLazyRequest = toStandardLazyRequest(req.raw) standardRequest.params = req.params return standardRequest }, }), ], }) export class AppModule {} ``` ## Typesafe Client After implementing your contract in NestJS, you can use the same contract to create a typesafe client. See [OpenAPI Link](/docs/openapi/link) for more details. --- # Next.js Integration Source: https://orpc.dev/docs/integrations/next ## Installation ```package-install npm install @orpc/next@beta ``` ## Server Functions Use `createServerFunction` to turn a [procedure](/docs/procedure) into a [server function](https://nextjs.org/docs/app/api-reference/directives/use-server). It accepts the same options as [server-side clients](/docs/client/server-side#router-clients), and the returned function accepts the same input as the original procedure. ```ts twoslash 'use server' import { os } from '@orpc/server' import { createServerFunction } from '@orpc/next' const procedure = os.handler(async () => 'Hello from oRPC + Next.js!') export const serverFunction = createServerFunction(procedure, { context: async () => { // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, interceptors: [] // <- add interceptors if needed }) ``` You can call the returned `serverFunction` from a client component. ```tsx 'use client' import { serverFunction } from './path/to/server/function' export default function Page() { const handleClick = async () => { const [error, message] = await serverFunction() if (!error) { console.log({ message }) } } return (
) } ``` Special Next.js errors such as [redirect](https://nextjs.org/docs/app/api-reference/functions/redirect) and [notFound](https://nextjs.org/docs/app/api-reference/functions/not-found) are rethrown so Next.js handles them normally. All other errors are serialized to `ORPCErrorJSON` and returned as the first element of the tuple. ### Typesafe Errors [Typesafe errors](/docs/error-handling#typesafe-errors) are supported as well. Because errors are serialized before they reach the client, use the `defined` field to distinguish errors. ```tsx client 'use client' import { serverFunction } from './path/to/server/function' export default function Page() { const handleClick = async () => { const [error, message] = await serverFunction() if (error) { if (error.defined) { // handle typesafe error } else { // handle unknown error } } else { // handle success case } } return (
) } ``` ```ts server 'use server' const procedure = os .errors({ NOT_FOUND: { message: 'The resource was not found', }, }) .handler(async ({ errors }) => { throw errors.NOT_FOUND() }) export const serverFunction = createServerFunction(procedure) ```
### `createServerFunctionable` If you reuse the same options across multiple server functions, `createServerFunctionable` creates a preconfigured helper. The helper takes a procedure and returns a value that works as both a server function and the original [procedure](/docs/procedure) on the server. ```ts import { createServerFunctionable } from '@orpc/next' const functionable = createServerFunctionable({ context: async () => { // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, }) // Works as both a server function and a procedure. export const functionableProcedure = functionable( os.handler(async () => 'Hello from oRPC + Next.js!') ) ``` ### `.actionable` Extension Import `@orpc/next/extensions/actionable` from a module that always runs during initialization, such as the file where you define your base builder. This adds an `.actionable` method to decorated procedures. Like `createServerFunctionable`, it returns a value that works as both a server function and a [procedure](/docs/procedure). ```ts usage export const functionableProcedure = base .handler(async () => 'Hello from oRPC + Next.js!') .actionable({ context: async () => { // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, }) ``` ```ts setup import '@orpc/next/extensions/actionable' import { os } from '@orpc/server' export const base = os ``` ### Hooks This integration also includes React hooks for server functions. `useServerFunction` executes a server function and tracks its status. `useOptimisticServerFunction` does the same, with optimistic updates. Unlike direct server function calls, hook errors are deserialized into native `ORPCError` instances instead of plain JSON (`ORPCErrorJSON`) for a more natural developer experience. ```tsx useServerFunction 'use client' import { isDefinedError } from '@orpc/client' import { getIssueMessage, onErrorDeferred, parseFormData, } from '@orpc/next' import { useServerFunction } from '@orpc/next/hooks' export function MyComponent() { const { execute, data, error, status } = useServerFunction(serverFunction, { interceptors: [ onErrorDeferred((error) => { if (isDefinedError(error)) { console.error(error.data) // ^ Typed error data } }), ], }) return (
execute(parseFormData(form))}> {getIssueMessage(error, 'name')} {status === 'pending' &&

Loading...

}
) } ``` ```tsx useOptimisticServerFunction 'use client' import { useOptimisticServerAction } from '@orpc/next/hooks' import { getIssueMessage, onSuccessDeferred, parseFormData, } from '@orpc/next' export function MyComponent() { const [todos, setTodos] = useState([]) const { execute, optimisticState } = useOptimisticServerAction(someAction, { optimisticPassthrough: todos, optimisticReducer: (currentState, newTodo) => [...currentState, newTodo], interceptors: [ onSuccessDeferred(({ data }) => { setTodos(prevTodos => [...prevTodos, data]) }), ], }) return (
    {optimisticState.map(todo => (
  • {todo.todo}
  • ))}
execute(parseFormData(form))}> {getIssueMessage(error, 'todo')}
) } ```
:::info Besides hooks, this integration also re-exports [form-data helpers](/docs/helpers/form-data) for working with `FormData`, as well as deferred interceptors for updating UI states: `onStartDeferred`, `onSuccessDeferred`, `onErrorDeferred`, and `onFinishDeferred`. ::: :::info You can use [`safe` and `isDefinedError`](/docs/client/error-handling#using-safe-and-isdefinederror) together for typesafe error handling in interceptors. ::: ## Server Form Functions Use `createServerFormFunction` to turn a procedure into a form action for `
`. Unlike `createServerFunction`, the returned function accepts `FormData` instead of the procedure input. It deserializes that data using [Bracket Notation](/docs/openapi/bracket-notation), then passes the result to the procedure. ```tsx client export default function Page() { return ( ) } ``` ```ts server 'use server' import { redirect } from 'next/navigation' const procedure = os .input(z.object({ name: z.string() })) .handler(async ({ input }) => { // do something }) export const serverFormFunction = createServerFormFunction(procedure, { interceptors: [ async ({ next }) => { await next() redirect('/thank-you') // redirect on success } ] }) ```
### `createServerFormFunctionable` If you reuse the same options across multiple form actions, `createServerFormFunctionable` creates a preconfigured helper. Like [`createServerFunctionable`](#createserverfunctionable), it takes a procedure and returns a value that works as both a server form function and the original [procedure](/docs/procedure). ```ts import { createServerFormFunctionable } from '@orpc/next' const formFunctionable = createServerFormFunctionable({ context: async () => { // <- provide initial context if needed return { user: { id: '123', name: 'Alice' } } }, }) // Works as both a server form function and a procedure. export const formFunctionableProcedure = formFunctionable( os.handler(async () => 'Hello from oRPC + Next.js!') ) ``` --- # OpenTelemetry Integration Source: https://orpc.dev/docs/integrations/opentelemetry :::warning This guide assumes familiarity with [OpenTelemetry](https://opentelemetry.io/). Review the official documentation if needed. ::: ![oRPC OpenTelemetry Integration Preview](/images/opentelemetry-integration-preview.png) :::info See the complete example in our [playgrounds](/docs/playgrounds). ::: ## Installation ```package-install npm install @orpc/opentelemetry@beta ``` ## Setup To integrate OpenTelemetry with oRPC, use `ORPCInstrumentation`. It automatically instruments both client and server for distributed tracing. ```ts server twoslash import { NodeSDK } from '@opentelemetry/sdk-node' import { ORPCInstrumentation } from '@orpc/opentelemetry' const sdk = new NodeSDK({ instrumentations: [ new ORPCInstrumentation(), // [!code highlight] ], }) sdk.start() ``` ```ts client twoslash import { WebTracerProvider } from '@opentelemetry/sdk-trace-web' import { registerInstrumentations } from '@opentelemetry/instrumentation' import { ORPCInstrumentation } from '@orpc/opentelemetry' const provider = new WebTracerProvider() provider.register() registerInstrumentations({ instrumentations: [ new ORPCInstrumentation(), // [!code highlight] ], }) ``` :::info You can configure OpenTelemetry for your server, client, or both, depending on your needs. ::: :::tip On Cloudflare Workers, the [Cloudflare Workers Traces integration](/docs/integrations/cloudflare-traces) records the same spans without an OpenTelemetry SDK. ::: ## Context Propagation By default, `ORPCInstrumentation` enables [context propagation](https://opentelemetry.io/docs/concepts/context-propagation/) between the client and server. You can disable it by setting `propagationEnabled` to `false` if you do not need it or if another instrumentation already handles it. ```ts const instrumentation = new ORPCInstrumentation({ propagationEnabled: false, }) ``` :::warning Popular instrumentations that already handle context propagation include [@hono/otel](https://www.npmjs.com/package/@hono/otel), [@opentelemetry/instrumentation-http](https://www.npmjs.com/package/@opentelemetry/instrumentation-http), and [@opentelemetry/instrumentation-fetch](https://www.npmjs.com/package/@opentelemetry/instrumentation-fetch). ::: ## Middleware Span oRPC automatically creates spans for each [middleware](/docs/middleware) execution. You can access the active span to customize attributes, events, and other span data: ```ts import { trace } from '@opentelemetry/api' export const someMiddleware = os.middleware(async (ctx, next) => { const span = trace.getActiveSpan() span?.setAttribute('someAttribute', 'someValue') span?.addEvent('someEvent') return next() }) Object.defineProperty(someMiddleware, 'name', { value: 'someName', }) ``` :::tip Define the `name` property on your middleware to improve span naming and make traces easier to read. ::: ## Capture Abort Signals If your application heavily uses [AsyncIteratorObject](/docs/async-iterator-object) or similar streaming patterns, we recommend capturing an event when the `signal` is aborted to properly track and detach unexpected long-running operations: ```ts import { trace } from '@opentelemetry/api' const handler = new RPCHandler(router, { interceptors: [ ({ request, next }) => { const span = trace.getActiveSpan() request.signal?.addEventListener('abort', () => { span?.addEvent('aborted', { reason: String(request.signal?.reason) }) }) return next() }, ], }) ``` --- # Pinia Colada Integration Source: https://orpc.dev/docs/integrations/pinia-colada :::warning This guide assumes you are already familiar with [Pinia Colada](https://pinia-colada.esm.dev/). If you need a refresher, review the official Pinia Colada documentation before continuing. ::: ## Installation ```package-install npm install @orpc/pinia-colada@beta ``` ## Setup Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). ```ts import { createPiniaColadaUtils } from '@orpc/pinia-colada' const orpc = createPiniaColadaUtils(client) ``` To avoid key conflicts when creating multiple sets of utils, pass a unique `prefix`. It becomes the first element of every entry key, so entries from different utils never overlap. ```ts const userORPC = createPiniaColadaUtils(userClient, { prefix: 'user' }) const postORPC = createPiniaColadaUtils(postClient, { prefix: 'post' }) ``` ## Query Options Utility Use `.queryOptions` to build query options. It works with `useQuery` and any other API that accepts query options. ```ts const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed // additional options... })) ``` :::info Options accept plain values only. For reactive inputs, pass a callback to `useQuery` as described in [Reactive Options](#reactive-options). ::: ## Streamed Query Options Utility Use `.streamedOptions` to build streamed query options for an [AsyncIteratorObject](/docs/async-iterator-object). The resulting data is an array of chunks, and each new chunk is appended as it arrives. It works with `useQuery` and any other API that accepts query options. ```ts const query = useQuery(orpc.streamed.streamedOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed fnOptions: { // Configure streamed query behavior refetchMode: 'reset', maxChunks: 3, }, // additional options... })) ``` :::info `refetchMode` determines how data is handled when the query is fetched again: - `'reset'` _(default)_: Clears existing data and returns the query to a pending state. - `'append'`: Adds new streamed chunks to the existing data. - `'replace'`: Buffers streamed data and replaces the cache after the stream completes. ::: ## Live Query Options Utility Use `.liveOptions` to build live query options for an [AsyncIteratorObject](/docs/async-iterator-object). The data always reflects the latest chunk, replacing the previous value whenever a new one arrives. It works with `useQuery` and any other API that accepts query options. ```ts const query = useQuery(orpc.live.liveOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed // additional options... })) ``` ## Infinite Query Options Utility Use `.infiniteOptions` to build infinite query options. It works with `useInfiniteQuery` and any other API that accepts infinite query options. :::info The `input` option must be a function that receives the page parameter and returns the query input. Define the `pageParam` type explicitly if it can be `null` or `undefined`. ::: ```ts const query = useInfiniteQuery(() => orpc.planet.list.infiniteOptions({ input: (offset: number) => ({ limit: 10, offset }), context: { cache: true }, // Provide client context if needed initialPageParam: 0, getNextPageParam: lastPage => lastPage.nextOffset, // additional options... })) ``` ## Mutation Options Use `.mutationOptions` to build mutation options. It works with `useMutation` and any other API that accepts mutation options. ```ts const mutation = useMutation(orpc.planet.create.mutationOptions({ context: { cache: true }, // Provide client context if needed // additional options... })) mutation.mutate({ name: 'Earth' }) ``` ## Query/Mutation Key oRPC provides helper methods for generating query and mutation keys: - `.key`: Generates a **partial-match** key for actions such as invalidating queries or checking mutation status. - `.queryKey`: Generates a **full-match** key for [Query Options](#query-options-utility). - `.streamedKey`: Generates a **full-match** key for [Streamed Query Options](#streamed-query-options-utility). - `.liveKey`: Generates a **full-match** key for [Live Query Options](#live-query-options-utility). - `.infiniteKey`: Generates a **full-match** key for [Infinite Query Options](#infinite-query-options-utility). - `.mutationKey`: Generates a **full-match** key for [Mutation Options](#mutation-options). ```ts const queryCache = useQueryCache() // Invalidate all planet queries queryCache.invalidateQueries({ key: orpc.planet.key(), }) // Invalidate only regular (non-infinite) planet queries queryCache.invalidateQueries({ key: orpc.planet.key({ type: 'query' }) }) // Invalidate the planet find query with id 123 queryCache.invalidateQueries({ key: orpc.planet.find.key({ input: { id: 123 } }) }) // Update the planet find query with id 123 queryCache.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old) => { return { ...old, id: 123, name: 'Earth' } }) ``` :::info Because Pinia Colada requires entry keys to be serializable, oRPC serializes inputs into JSON-compatible values (including native types like `Date`, `URL`, `BigInt`, etc.) when building keys. ::: ## Calling Procedure Clients The `.call` method provides direct access to the underlying procedure client when needed. ```ts const planet = await orpc.planet.find.call({ id: 123 }) ``` ## Reactive Options Option utilities accept plain values only. For reactive inputs, pass a callback to `useQuery` instead — it re-evaluates whenever its dependencies change. ```ts const id = ref(123) const query = useQuery(() => orpc.planet.find.queryOptions({ input: { id: id.value }, })) ``` ## Default Options Use `scoped` to configure default options for scoped query and mutation utilities. Each value can be either a partial options object, which is spread-merged with lower priority than per-call options, or a function that receives the per-call options and returns the merged result. ```ts const orpc = createPiniaColadaUtils(client, { scoped: { planet: { find: { queryKey: options => ({ // Override the auto-generated key for .queryKey and .queryOptions key: options.key ?? ['planet', 'find', options.input] }), queryOptions: { staleTime: 60 * 1000, // 1 minute }, }, create: { mutationOptions: { onSuccess: () => { // runs for every planet.create mutation }, }, }, }, }, }) // These calls automatically use the default options const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) const mutation = useMutation(orpc.planet.create.mutationOptions()) // User-provided options take precedence const customQuery = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 }, staleTime: 0, // overrides the default staleTime })) ``` :::info When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate keys. The same applies to infinite and mutation options when you configure their keys. ::: ## Interceptors Interceptors let you wrap `query` and `mutation` calls. Unlike [default options](#default-options), which can be overridden by per-call options, interceptors always run for every query and mutation. ```ts import { isDefinedError, safe } from '@orpc/client' const orpc = createPiniaColadaUtils(client, { queryInterceptors: [], streamedInterceptors: [], liveInterceptors: [], infiniteInterceptors: [], mutationInterceptors: [ async ({ context, path, next }) => { const [error, data] = await safe(next()) if (error) { if (isDefinedError(error)) { // handle typesafe errors } throw error } return data } ], }) ``` :::info You can use [`safe` and `isDefinedError`](/docs/client/error-handling#using-safe-and-isdefinederror) together for typesafe error handling in interceptors. ::: ## Plugins Plugins package reusable defaults and interceptors for queries and mutations. ```ts const orpc = createPiniaColadaUtils(client, { plugins: [] }) ``` ### Contract Options Plugin Use `piniaColada` to define base options and interceptors directly on a [procedure contract](/docs/contract/procedure), then pass the contract to `ContractOptionsUtilsPlugin` to apply them automatically. Meta options act as the base layer: [default options](#default-options) and [interceptors](#interceptors) defined on the utils merge on top of them. Passing `undefined` explicitly for a key resets the value from lower layers instead of merging. ```ts import { ContractOptionsUtilsPlugin, piniaColada } from '@orpc/pinia-colada' export const contract = { planet: { find: oc .input(z.object({ id: z.number() })) .meta(piniaColada({ queryOptions: { staleTime: 60 * 1000, }, queryInterceptors: [ async ({ input, next }) => { // input, output, and errors are typed based on the contract return await next() }, ], })), }, } const orpc = createPiniaColadaUtils(client, { plugins: [new ContractOptionsUtilsPlugin(contract)], }) ``` :::warning Types inferred from the contract are for reference only. The actual types depend on the client the utils are created from. For example, a `JsonifiedClient` created from [OpenAPI Link](/docs/openapi/link#typesafe-clients) returns jsonified outputs that may not match the contract schemas. ::: Contracts are defined separately from your app, so anything inside `piniaColada` cannot import runtime values such as your router utils. Instead, augment [`UseMutationContextCommon`](https://pinia-colada.esm.dev/api/@pinia/colada/interfaces/UseMutationContextCommon.html) and provide the values through a global `onMutate` hook, which merges them into the `fnContext` of every mutation. The example below reads router utils and the query cache from `fnContext` to optimistically update a query: ```ts import type { RouterContractClient } from '@orpc/contract' import type { RouterUtils } from '@orpc/pinia-colada' import type { QueryCache } from '@pinia/colada' declare module '@pinia/colada' { interface UseMutationContextCommon { utils: RouterUtils> queryCache: QueryCache } } export const contract = { planet: { find: oc.input(z.object({ id: z.number() })), update: oc .input(z.object({ id: z.number(), name: z.string() })) .meta(piniaColada({ mutationInterceptors: [ async ({ input, next, fnContext }) => { const { utils, queryCache } = fnContext if (!utils || !queryCache) { return next() } const queryKey = utils.planet.find.queryKey({ input: { id: input.id } }) const previous = queryCache.getQueryData(queryKey) // optimistically update before the request queryCache.setQueryData(queryKey, input) try { return await next() } catch (error) { // roll back on error queryCache.setQueryData(queryKey, previous) throw error } finally { queryCache.invalidateQueries({ key: queryKey }) } }, ], })), }, } app.use(PiniaColada, { mutationOptions: { onMutate: () => ({ utils: orpc, queryCache: useQueryCache(pinia), }), }, }) ``` ## Client Context When a client is invoked through the Pinia Colada integration, an **operation context** is automatically added to the [client context](/docs/client/client-side#client-context). You can use this context to configure request behavior, such as selecting the HTTP method for [RPC Link](/docs/rpc/link#request-method). ```ts import { PINIA_COLADA_OPERATION_CONTEXT_SYMBOL, PiniaColadaOperationContext, } from '@orpc/pinia-colada' import { RPCLink } from '@orpc/client/fetch' interface ClientContext extends PiniaColadaOperationContext { } const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) const link = new RPCLink({ method: ({ context }) => { const operationType = context[PINIA_COLADA_OPERATION_CONTEXT_SYMBOL]?.type if (operationType && GET_OPERATION_TYPE.has(operationType)) { return 'GET' } return 'POST' }, }) ``` ## Typesafe Error Handling Use the built-in `isDefinedError` helper to handle [typesafe errors](/docs/error-handling#typesafe-errors) in queries and mutations. ```ts import { isDefinedError } from '@orpc/client' const mutation = useMutation(orpc.planet.create.mutationOptions({ onError: (error) => { if (isDefinedError(error)) { // Handle typesafe errors here } } })) mutation.mutate({ name: 'Earth' }) if (mutation.error.value && isDefinedError(mutation.error.value)) { // Handle the typesafe errors here } ``` --- # Pino Integration Source: https://orpc.dev/docs/integrations/pino :::warning This guide assumes familiarity with [Pino](https://getpino.io/). Review the official documentation if needed. ::: ## Installation ```package-install npm install @orpc/pino@beta pino@beta ``` ## Setup To set up Pino with oRPC, use the `PinoHandlerPlugin` class. This plugin automatically instruments your handler with structured logging, request tracking, and error monitoring. ```ts twoslash import { RPCHandler } from '@orpc/server/fetch' import { router } from './shared/planet' // ---cut--- import { PinoHandlerPlugin } from '@orpc/pino' import pino from 'pino' const logger = pino() const handler = new RPCHandler(router, { plugins: [ new PinoHandlerPlugin({ logger, // <- custom logger instance generateRequestId: ({ request }) => crypto.randomUUID(), // <- custom request id generator logLifecycle: true, // <- log information about request lifecycle (disabled by default) logAbort: true, // <- log information when requests are aborted (disabled by default) }), ], }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: :::tip For improved log readability during development, consider using [pino-pretty](https://github.com/pinojs/pino-pretty) to format your logs in a human-friendly way. ```bash npm run dev | npx pino-pretty ``` ::: ## Error Logging Levels Errors thrown from your procedures are logged at a level matching their intent: `info` for abort errors, `warn` for `ORPCError` instances since they represent deliberate rejections, and `error` for everything else, including `ORPCError` with the `INTERNAL_SERVER_ERROR` code. Use the `procedureErrorLevel` option to customize this behavior: ```ts const plugin = new PinoHandlerPlugin({ procedureErrorLevel: (error, level) => { if (level === 'error' && error instanceof ExpectedError) { return 'warn' } return level }, }) ``` ## Using the Logger in Your Code You can access the logger from the context object using the `getLogger` function: ```ts import { getLogger, LoggerContext } from '@orpc/pino' interface ServerContext extends LoggerContext {} // [!code highlight] const procedure = os .$context() .handler(({ context }) => { const logger = getLogger(context) // [!code highlight] logger?.info('Processing request') logger?.debug({ userId: 123 }, 'User data') return { success: true } }) ``` ## Providing Custom Logger per Request You can provide a custom logger instance for specific requests by passing it through the context. This is especially useful when integrating with [pino-http](https://github.com/pinojs/pino-http) for enhanced HTTP logging: ```ts import { LOGGER_CONTEXT_SYMBOL, LoggerContext, PinoHandlerPlugin } from '@orpc/pino' const logger = pino() const httpLogger = pinoHttp({ logger }) interface ServerContext extends LoggerContext {} // [!code highlight] const router = { ping: os.$context().handler(() => 'pong') } const handler = new RPCHandler(router, { plugins: [ new PinoHandlerPlugin({ logger }), // [!code highlight] ], }) const server = createServer(async (req, res) => { httpLogger(req, res) const { matched } = await handler.handle(req, res, { prefix: '/api', context: { [LOGGER_CONTEXT_SYMBOL]: req.log, // [!code highlight] }, }) if (!matched) { res.statusCode = 404 res.end('Not Found') } }) ``` --- # Standard Schema Integration Source: https://orpc.dev/docs/integrations/standard-schema oRPC natively supports any library that implements the [Standard Schema](https://standardschema.dev/) specification, such as [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), [ArkType](/docs/integrations/arktype), and [many more](https://standardschema.dev/schema#what-schema-libraries-implement-the-spec). Use them directly in `.input`, `.output`, and `.errors` without any extra setup. ```ts import * as z from 'zod' import * as v from 'valibot' const example = os .input(z.object({ name: z.string() })) .output(v.object({ name: v.string() })) ``` ## Standard JSON Schema [Standard JSON Schema](https://standardschema.dev/json-schema) is a companion specification that lets a schema library expose JSON Schema conversion in a standard way. Tools that rely on JSON Schema converters, such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator) and [Smart Coercion](/docs/plugins/smart-coercion), automatically fall back to Standard JSON Schema conversion when no configured converter matches a schema. So if your library also implements Standard JSON Schema, these tools work out of the box without a dedicated converter. Otherwise, the schema is treated as unknown and converted to an empty JSON schema. ### Building Your Own Converter If your library does not implement Standard JSON Schema, or you want more control over the conversion, you can build your own converter by implementing the `JsonSchemaConverter` interface and passing it to the `converters` option. The first converter whose `condition` matches handles the schema: ```ts import type { AnySchema } from '@orpc/contract' import type { JsonSchema, JsonSchemaConverter, JsonSchemaConverterDirection } from '@orpc/json-schema' import { toJsonSchema } from '@valibot/to-json-schema' class MyCustomConverter implements JsonSchemaConverter { condition(schema: AnySchema | undefined, _direction: JsonSchemaConverterDirection): boolean { return schema?.['~standard'].vendor === 'valibot' } convert( schema: AnySchema | undefined, direction: JsonSchemaConverterDirection ): [jsonSchema: JsonSchema, optional: boolean] { // In most cases, treating the schema as required is acceptable. return [toJsonSchema(schema as any), false] as any } } ``` --- # SWR Integration Source: https://orpc.dev/docs/integrations/swr :::warning This guide assumes you are already familiar with [SWR](https://swr.vercel.app/). If you need a refresher, review the official SWR documentation before continuing. ::: ## Installation ```package-install npm install @orpc/swr@beta ``` ## Setup Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). ```ts import { createSWRUtils } from '@orpc/swr' export const orpc = createSWRUtils(client) orpc.planet.find.key({ input: { id: 123 } }) ``` You can avoid key conflicts by passing a unique prefix when creating your utils: ```ts const userORPC = createSWRUtils(userClient, { prefix: 'user' }) const postORPC = createSWRUtils(postClient, { prefix: 'post' }) ``` ## Data Fetching Use `.key` and `.fetcher` methods to configure `useSWR` for data fetching: ```ts import useSWR from 'swr' const { data, error, isLoading } = useSWR( orpc.planet.find.key({ input: { id: 123 } }), orpc.planet.find.fetcher({ context: { cache: true } }), // Provide client context if needed ) ``` ## Infinite Queries Use `.key` and `.fetcher` methods to configure `useSWRInfinite` for infinite queries: ```ts import useSWRInfinite from 'swr/infinite' const { data, error, isLoading, size, setSize } = useSWRInfinite( (index, previousPageData) => { if (previousPageData && !previousPageData.nextCursor) { return null // reached the end } return orpc.planet.list.key({ input: { cursor: previousPageData?.nextCursor } }) }, orpc.planet.list.fetcher({ context: { cache: true } }), // Provide client context if needed ) ``` ## Subscriptions Use `.key` and `.subscriber` methods to configure `useSWRSubscription` to subscribe to an [AsyncIteratorObject](/docs/async-iterator-object): ```ts import useSWRSubscription from 'swr/subscription' const { data, error } = useSWRSubscription( orpc.streamed.key({ input: { id: 3 } }), orpc.streamed.subscriber({ context: { cache: true }, maxChunks: 10 }), // Provide client context if needed ) ``` Use `.liveSubscriber` to subscribe to the latest events without chunking: ```ts import useSWRSubscription from 'swr/subscription' const { data, error } = useSWRSubscription( orpc.streamed.key({ input: { id: 3 } }), orpc.streamed.liveSubscriber({ context: { cache: true } }), // Provide client context if needed ) ``` ## Mutations Use `.key` and `.mutator` methods to configure `useSWRMutation` for mutations with automatic revalidation on success: ```ts import useSWRMutation from 'swr/mutation' const { trigger, isMutating } = useSWRMutation( orpc.planet.list.key(), orpc.planet.create.mutator({ context: { cache: true } }), // Provide client context if needed ) trigger({ name: 'New Planet' }) // auto revalidate orpc.planet.list.key() on success ``` ## Manual Revalidation Use `.matcher` to invalidate data manually: ```ts import { mutate } from 'swr' mutate(orpc.matcher()) // invalidate all orpc data mutate(orpc.planet.matcher()) // invalidate all planet data mutate(orpc.planet.find.matcher({ input: { id: 123 }, strategy: 'exact' })) // invalidate specific planet data ``` ## Calling Clients Use `.call` to call a procedure client directly. It's an alias for corresponding procedure client. ```ts const planet = await orpc.planet.find.call({ id: 123 }) ``` ## Operation Context When clients are invoked through the SWR integration, an **operation context** is automatically added to the [client context](/docs/rpc/link#client-context). This context can be used to configure the request behavior, like setting the HTTP method. ```ts import { SWR_OPERATION_CONTEXT_SYMBOL, SWROperationContext, } from '@orpc/swr' interface ClientContext extends SWROperationContext { } const GET_OPERATION_TYPE = new Set(['fetcher', 'subscriber', 'liveSubscriber']) const link = new RPCLink({ method: ({ context }, path) => { const operationType = context[SWR_OPERATION_CONTEXT_SYMBOL]?.type if (operationType && GET_OPERATION_TYPE.has(operationType)) { return 'GET' } return 'POST' }, }) ``` --- # TanStack AI Integration Source: https://orpc.dev/docs/integrations/tanstack-ai :::warning This documentation is based on TanStack AI v0.x, which is still evolving. For a refresher, review the [TanStack AI documentation](https://tanstack.com/ai/latest). ::: ## Transport TanStack AI's `chat` returns an [AsyncIteratorObject](/docs/async-iterator-object) of stream chunks, so a procedure can return it directly and oRPC streams every chunk to the client. ### Server ```ts import type { UIMessage } from '@tanstack/ai' import { os, type } from '@orpc/server' import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' export const streamChat = os .input(type<{ messages: UIMessage[] }>()) .handler(({ input, signal }) => { const abortController = new AbortController() signal?.addEventListener('abort', () => abortController.abort(), { once: true }) return chat({ adapter: openaiText('gpt-5.5'), systemPrompts: ['You are a helpful assistant.'], messages: input.messages, abortController, }) }) ``` ### Client Pass an oRPC client call as the `fetcher` of `useChat`. The `fetcher` option accepts a promise of an `AsyncIterable` of stream chunks, which is exactly what an oRPC client call returns. ```tsx import { useState } from 'react' import { useChat } from '@tanstack/ai-react' import { client } from './client' export function Example() { const { messages, sendMessage, isLoading } = useChat({ fetcher: ({ messages }, { signal }) => client.streamChat({ messages }, { signal }), }) const [input, setInput] = useState('') return ( <> {messages.map(message => (
{message.role === 'user' ? 'User: ' : 'AI: '} {message.parts.map((part, index) => part.type === 'text' ? {part.content} : null, )}
))}
{ e.preventDefault() if (input.trim() && !isLoading) { sendMessage(input) setInput('') } }} > setInput(e.target.value)} disabled={isLoading} placeholder="Say something..." />
) } ``` :::tip With [OpenAPIHandler](/docs/openapi/handler), TanStack AI's built-in `fetchServerSentEvents` connection can talk to a [routed](/docs/openapi/routing) procedure directly, no custom fetcher needed: oRPC streams [event iterators](/docs/async-iterator-object) as standard [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events). ```ts export const streamChat = os .meta(openapi({ method: 'POST', path: '/chat' })) .input(type<{ messages: UIMessage[] }>()) .handler(({ input }) => chat({ adapter: openaiText('gpt-5.5'), messages: input.messages, })) const { messages, sendMessage, isLoading } = useChat({ connection: fetchServerSentEvents('/api/chat'), // handler prefix + route path }) ``` ::: --- # TanStack Query Integration Source: https://orpc.dev/docs/integrations/tanstack-query :::warning This guide assumes you are already familiar with [TanStack Query](https://tanstack.com/query/latest). If you need a refresher, review the official TanStack Query documentation before continuing. ::: ## Installation ```package-install npm install @orpc/tanstack-query@beta ``` ## Setup Before you begin, set up either a [server-side client](/docs/client/server-side) or a [client-side client](/docs/client/client-side). ```ts twoslash import { client } from './shared/planet' // ---cut--- import { createTanstackQueryUtils } from '@orpc/tanstack-query' const orpc = createTanstackQueryUtils(client) orpc.planet.find.queryOptions({ input: { id: 123 } }) // ^| // // // // // // ``` To avoid key conflicts when creating multiple sets of utils, pass a unique `prefix`. It becomes the first element of every query/mutation key, so keys from different utils never overlap. ```ts const userORPC = createTanstackQueryUtils(userClient, { prefix: 'user' }) const postORPC = createTanstackQueryUtils(postClient, { prefix: 'post' }) ``` ## Query Options Use `.queryOptions` to build query options. It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed // additional options... })) ``` ## Streamed Query Options Use `.streamedOptions` to build streamed query options for an [AsyncIteratorObject](/docs/async-iterator-object). The resulting data is an array of events, and each new event is appended as it arrives. It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts const query = useQuery(orpc.streamed.streamedOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed queryFnOptions: { // Configure streamed query behavior refetchMode: 'reset', maxChunks: 3, }, retry: true, // Infinite retry for more reliable streaming // additional options... })) ``` :::info `refetchMode` determines how data is handled when the query is fetched again: - `'reset'` _(default)_: Clears existing data and returns the query to a pending state. - `'append'`: Adds new streamed chunks to the existing data. - `'replace'`: Buffers streamed data and replaces the cache after the stream completes. ::: ## Live Query Options Use `.liveOptions` to build live query options for an [AsyncIteratorObject](/docs/async-iterator-object). The data always reflects the latest event, replacing the previous value whenever a new one arrives. It works with `useQuery`, `useSuspenseQuery`, and `prefetchQuery`, and any other API that accepts query options. ```ts const query = useQuery(orpc.live.liveOptions({ input: { id: 123 }, // Specify input if needed context: { cache: true }, // Provide client context if needed retry: true, // Infinite retry for more reliable streaming // additional options... })) ``` ## Infinite Query Options Use `.infiniteOptions` to build infinite query options. It works with `useInfiniteQuery`, `useSuspenseInfiniteQuery`, and `prefetchInfiniteQuery`, and any other API that accepts infinite query options. :::info The `input` option must be a function that receives the page parameter and returns the query input. Define the `pageParam` type explicitly if it can be `null` or `undefined`. ::: ```ts const query = useInfiniteQuery(orpc.planet.list.infiniteOptions({ input: (pageParam: number | undefined) => ({ limit: 10, offset: pageParam }), context: { cache: true }, // Provide client context if needed initialPageParam: undefined, getNextPageParam: lastPage => lastPage.nextPageParam, // additional options... })) ``` ## Mutation Options Use `.mutationOptions` to build mutation options. It works with `useMutation` and any other API that accepts mutation options. ```ts const mutation = useMutation(orpc.planet.create.mutationOptions({ context: { cache: true }, // Provide client context if needed // additional options... })) mutation.mutate({ name: 'Earth' }) ``` ## Query and Mutation Keys oRPC provides helper methods for generating query and mutation keys: - `.key`: Generates a **partial-match** key for actions such as invalidating queries or checking mutation status. - `.queryKey`: Generates a **full-match** key for [Query Options](#query-options). - `.streamedKey`: Generates a **full-match** key for [Streamed Query Options](#streamed-query-options). - `.liveKey`: Generates a **full-match** key for [Live Query Options](#live-query-options). - `.infiniteKey`: Generates a **full-match** key for [Infinite Query Options](#infinite-query-options). - `.mutationKey`: Generates a **full-match** key for [Mutation Options](#mutation-options). ```ts const queryClient = useQueryClient() // Invalidate all planet queries queryClient.invalidateQueries({ queryKey: orpc.planet.key(), }) // Invalidate only regular (non-infinite) planet queries queryClient.invalidateQueries({ queryKey: orpc.planet.key({ type: 'query' }) }) // Invalidate the planet find query with id 123 queryClient.invalidateQueries({ queryKey: orpc.planet.find.key({ input: { id: 123 } }) }) // Update the planet find query with id 123 queryClient.setQueryData(orpc.planet.find.queryKey({ input: { id: 123 } }), (old) => { return { ...old, id: 123, name: 'Earth' } }) ``` ## Calling Clients The `.call` method provides direct access to the underlying procedure client when needed. ```ts const planet = await orpc.planet.find.call({ id: 123 }) ``` ## Reactive Options In reactive libraries like Vue or Solid, TanStack Query supports passing computed values as options. The exact API varies by framework, so refer to the TanStack Query documentation for [Vue](https://tanstack.com/query/latest/docs/framework/vue/reactivity) or [Solid](https://tanstack.com/query/latest/docs/framework/solid/reference/useQuery#reactive-options). ```ts title="Options as Function" const query = useQuery( () => orpc.planet.find.queryOptions({ input: { id: id() }, }) ) ``` ```ts title="Computed Options" const query = useQuery(computed( () => orpc.planet.find.queryOptions({ input: { id: id.value }, }) )) ``` ## Default Options Use `scoped` to configure default options for scoped query and mutation utilities. Each value can be either a partial options object, which is spread-merged with lower priority than per-call options, or a function that receives the per-call options and returns the merged result. ```ts const orpc = createTanstackQueryUtils(client, { scoped: { planet: { find: { queryKey: options => ({ // Override the auto-generated query key for .queryKey and .queryOptions queryKey: options.queryKey ?? ['planet', 'find', options.input] }), queryOptions: { staleTime: 60 * 1000, // 1 minute retry: 3, }, }, list: { infiniteOptions: options => ({ ...options, staleTime: 30 * 1000, // override takes priority }), }, create: { mutationOptions: { onSuccess: (output, input, _, ctx) => { ctx.client.invalidateQueries({ queryKey: orpc.planet.key() }) }, }, }, }, }, }) // These calls automatically use the default options const query = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 } })) const mutation = useMutation(orpc.planet.create.mutationOptions()) // User-provided options take precedence const customQuery = useQuery(orpc.planet.find.queryOptions({ input: { id: 123 }, staleTime: 0, // overrides the default staleTime })) ``` :::info When you configure `queryKey`, it also affects `.queryOptions` because it is used internally to generate query keys. The same applies to live, streamed, infinite, and mutation options when you configure their keys. ::: ## Interceptors Interceptors let you wrap `queryFn` and `mutationFn` calls. Unlike [default options](#default-options), which can be overridden by per-call options, interceptors always run for every query and mutation. ```ts import { isDefinedError, safe } from '@orpc/client' const orpc = createTanstackQueryUtils(client, { queryInterceptors: [], liveInterceptors: [], streamedInterceptors: [], infiniteInterceptors: [], mutationInterceptors: [ async ({ context, path, next }) => { const [error, data] = await safe(next()) if (error) { if (isDefinedError(error)) { // handle typesafe errors } throw error } return data } ], scoped: { planet: { create: { mutationInterceptors: [ async ({ next, fnContext }) => { const result = await next() fnContext.client.invalidateQueries({ queryKey: orpc.planet.key() }) return result }, ], }, }, }, }) ``` :::info You can use [`safe` and `isDefinedError`](/docs/client/error-handling#using-safe-and-isdefinederror) together for typesafe error handling in interceptors. ::: ## Plugins Plugins package reusable defaults and interceptors for queries and mutations. ```ts const orpc = createTanstackQueryUtils(client, { plugins: [] }) ``` ### Contract Options Plugin Use `tanstackQuery` to define base options and interceptors directly on a [procedure contract](/docs/contract/procedure), then pass the contract to `ContractOptionsUtilsPlugin` to apply them automatically. Meta options act as the base layer: [default options](#default-options) and [interceptors](#interceptors) defined on the utils merge on top of them. Passing `undefined` explicitly for a key resets the value from lower layers instead of merging. ```ts import { ContractOptionsUtilsPlugin, tanstackQuery } from '@orpc/tanstack-query' export const contract = { planet: { find: oc .input(z.object({ id: z.number() })) .meta(tanstackQuery({ queryOptions: { staleTime: 60 * 1000, }, queryInterceptors: [ async ({ input, next }) => { // input, output, and errors are typed based on the contract return await next() }, ], })), }, } const orpc = createTanstackQueryUtils(client, { plugins: [new ContractOptionsUtilsPlugin(contract)], }) ``` :::warning Types inferred from the contract are for reference only. The actual types depend on the client the utils are created from. For example, a `JsonifiedClient` created from [OpenAPI Link](/docs/openapi/link#typesafe-clients) returns jsonified outputs that may not match the contract schemas. ::: Contracts are defined separately from your app, so anything inside `tanstackQuery` cannot import runtime values such as your router utils. Instead, [register a global meta type](https://tanstack.com/query/latest/docs/framework/react/typescript#registering-global-meta) and pass the values through the `meta` option, per hook or globally via query client default options. The example below reads router utils from `fnContext.meta` to optimistically update a query: ```ts import type { RouterContractClient } from '@orpc/contract' import type { RouterUtils } from '@orpc/tanstack-query' declare module '@tanstack/react-query' { interface Register { mutationMeta: { utils?: RouterUtils> } } } export const contract = { planet: { find: oc.input(z.object({ id: z.number() })), update: oc .input(z.object({ id: z.number(), name: z.string() })) .meta(tanstackQuery({ mutationInterceptors: [ async ({ input, next, fnContext }) => { const utils = fnContext.meta?.utils if (!utils) { return next() } const queryKey = utils.planet.find.queryKey({ input: { id: input.id } }) const previous = fnContext.client.getQueryData(queryKey) // optimistically update before the request fnContext.client.setQueryData(queryKey, input) try { return await next() } catch (error) { // roll back on error fnContext.client.setQueryData(queryKey, previous) throw error } finally { fnContext.client.invalidateQueries({ queryKey }) } }, ], })), }, } const queryClient = new QueryClient({ defaultOptions: { mutations: { meta: { utils: orpc }, }, }, }) ``` ## Client Context :::warning oRPC excludes [client context](/docs/client/client-side#client-context) from query keys. Override the query key manually when you need to prevent unintended query deduplication. ```ts const query = useQuery(orpc.planet.find.queryOptions({ context: { cache: true }, // manually include context in the query key queryKey: [['planet', 'find'], { context: { cache: true } }], // additional options... })) ``` ::: When a client is invoked through the TanStack Query integration, an **operation context** is automatically added to the [client context](/docs/client/client-side#client-context). You can use this context to configure request behavior, such as selecting the HTTP method for [RPC Link](/docs/rpc/link#request-method). ```ts twoslash import { RPCLink } from '@orpc/client/fetch' // ---cut--- import { TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL, TanstackQueryOperationContext, } from '@orpc/tanstack-query' interface ClientContext extends TanstackQueryOperationContext { } const GET_OPERATION_TYPE = new Set(['query', 'streamed', 'live', 'infinite']) const link = new RPCLink({ method: ({ context }) => { const operationType = context[TANSTACK_QUERY_OPERATION_CONTEXT_SYMBOL]?.type if (operationType && GET_OPERATION_TYPE.has(operationType)) { return 'GET' } return 'POST' }, }) ``` ## Typesafe Error Handling Use the built-in `isDefinedError` helper to handle [typesafe errors](/docs/error-handling#typesafe-errors) in queries and mutations. ```ts import { isDefinedError } from '@orpc/client' const mutation = useMutation(orpc.planet.create.mutationOptions({ onError: (error) => { if (isDefinedError(error)) { // Handle typesafe errors here } } })) mutation.mutate({ name: 'Earth' }) if (mutation.error && isDefinedError(mutation.error)) { // Handle the typesafe errors here } ``` ## `skipToken` for Disabling Queries The [skipToken symbol](https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries#typesafe-disabling-of-queries-using-skiptoken) provides a typesafe alternative to setting `enabled: false` when you want to disable a query by omitting its `input`. ```ts const query = useQuery( orpc.planet.list.queryOptions({ input: search ? { search } : skipToken, // [!code highlight] }) ) const query = useInfiniteQuery( orpc.planet.list.infiniteOptions({ input: search // [!code highlight] ? (offset: number | undefined) => ({ limit: 10, offset, search }) // [!code highlight] : skipToken, // [!code highlight] initialPageParam: undefined, getNextPageParam: lastPage => lastPage.nextPageParam, }) ) ``` ## Server-Side Rendering (SSR) When [server-side rendering](/docs/recipes/optimizing-ssr) with [TanStack Query hydration](https://tanstack.com/query/latest/docs/framework/react/guides/ssr), two things need special care: oRPC types that JSON cannot represent, and streamed or live queries that never complete. ### Custom Serializers If needed, you can extend the default TanStack Query serializer to support additional types supported by oRPC. Learn more about the [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer). ```ts import { RPCJsonSerializer } from '@orpc/client' import { hashKey, QueryClient } from '@tanstack/react-query' // or any framework adapter, e.g. @tanstack/vue-query // similar to `RPCSerializer` but more typesafe const serializer = new RPCJsonSerializer({ handlers: { // put custom serializers here }, }) const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, // > 0 to prevent immediate refetching on mount queryKeyHashFn: (queryKey) => { const { json, meta } = serializer.serialize(queryKey) return hashKey([ json, meta?.map(entry => JSON.stringify(entry)).sort(), ]) }, }, dehydrate: { serializeData: (data) => { const { json, meta } = serializer.serialize(data) return { json, meta } }, }, hydrate: { deserializeData(data) { return serializer.deserialize(data) }, }, }, }) ``` ### Streamed and Live Queries [Streamed](#streamed-query-options) and [live](#live-query-options) queries can stay open indefinitely, so prefetching them during SSR would block rendering forever. The fix: cancel the stream on the server once the query succeeds and dehydrate the result, then refetch on the client to open a new stream. #### Cancel Server Streams on Success Only active streams hold `success` status while still `fetching`: streamed queries as soon as they connect, live queries once the first event arrives. Silently cancel queries in that state; the data received so far is kept, so prefetching settles and dehydration works as usual. ```ts export function createServerQueryClient(): QueryClient { const queryClient = new QueryClient() if (typeof window === 'undefined') { cancelStreamsOnSuccess(queryClient) } return queryClient } function cancelStreamsOnSuccess(queryClient: QueryClient): void { const cancelled = new Set() queryClient.getQueryCache().subscribe(({ query }) => { if ( query.state.status !== 'success' // no successful snapshot yet || query.state.fetchStatus !== 'fetching' // already settled || cancelled.has(query.queryHash) ) { return } cancelled.add(query.queryHash) void query.cancel({ silent: true }) }) } ``` :::warning Server-side query clients only. In the browser this would cancel active streams and background refetches. ::: #### Resume Streams on the Client Hydrated data is a static snapshot, and a positive `staleTime` like the one [configured above](#custom-serializers) marks it fresh, so the client never refetches and no new stream opens. Fix this with `refetchOnMount: 'always'`, either globally on the browser query client: ```ts const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 60 * 1000, refetchOnMount: 'always', }, }, }) ``` Or scoped to streamed and live queries with a [plugin](#plugins). Call sites can still override these defaults. ```ts import type { RouterUtilsPlugin } from '@orpc/tanstack-query' const streamingSSRPlugin: RouterUtilsPlugin = { name: 'streaming-ssr', initProcedureOptions(_path, options) { return { ...options, streamedOptions: { ...options.streamedOptions, initialData: [], refetchOnMount: 'always', }, liveOptions: { ...options.liveOptions, refetchOnMount: 'always', }, } }, } export const orpc = createTanstackQueryUtils(client, { plugins: [streamingSSRPlugin], }) ``` --- # tRPC Integration Source: https://orpc.dev/docs/integrations/trpc ## Installation ```package-install npm install @orpc/trpc@beta ``` ## Router Conversion `toORPCRouter` converts a [tRPC router](https://trpc.io/docs/server/routers) into an [oRPC router](/docs/router): ```ts import { toORPCRouter } from '@orpc/trpc' const orpcRouter = toORPCRouter(trpcRouter) ``` The result is a regular oRPC router that works with any oRPC feature. For example, you can expose it through an [RPC Handler](/docs/rpc/handler) or [OpenAPI Handler](/docs/openapi/handler), or call it directly with [Server-Side Clients](/docs/client/server-side). ### Error Formatting `toORPCRouter` does not support [tRPC Error Formatting](https://trpc.io/docs/server/error-formatting). Instead, errors thrown by tRPC are wrapped in `ORPCError`. ```ts const handler = new OpenAPIHandler(orpcRouter, { interceptors: [ async ({ next }) => { try { return await next() } catch (error) { if ( error instanceof ORPCError && error.cause instanceof TRPCError && error.cause.cause instanceof z.ZodError ) { throw new ORPCError('UNPROCESSABLE_CONTENT', { message: z.prettifyError(error.cause.cause), data: z.flattenError(error.cause.cause), cause: error.cause.cause, }) } throw error } }, ], }) ``` ## Metadata `toTRPCMeta` bridges [oRPC metadata](/docs/metadata) with tRPC meta. It returns a plain object that you can pass to tRPC `.meta` calls. ```ts import { openapi } from '@orpc/openapi' import { toTRPCMeta } from '@orpc/trpc' export const t = initTRPC.context().create() const example = t.procedure .meta(toTRPCMeta(openapi({ path: '/hello', summary: 'Hello procedure' }))) // [!code highlight] .input(z.object({ name: z.string() })) .query(({ input }) => { return `Hello, ${input.name}!` }) const merged = t.procedure .meta({ ...toTRPCMeta( // [!code highlight] openapi({ path: '/hello' }), // [!code highlight] openapi({ method: 'POST' }), // [!code highlight] ), // [!code highlight] other: 'value', }) .input(z.object({ name: z.string() })) .mutation(({ input }) => { return `Hello, ${input.name}!` }) ``` :::warning Chained tRPC `.meta()` calls merge shallowly, so oRPC metadata merge logic (e.g. accumulating `openapi.tags`) only works within a single `toTRPCMeta` call. ::: --- # Valibot Integration Source: https://orpc.dev/docs/integrations/valibot :::info [Valibot](https://valibot.dev/) implements [Standard Schema](/docs/integrations/standard-schema), so procedures accept Valibot schemas without any converter. The converter below is only needed by tools that consume JSON Schema, such as OpenAPI generation and Smart Coercion. ::: ## Installation ```package-install npm install @orpc/valibot@beta valibot ``` ## JSON Schema Converter `ValibotToJsonSchemaConverter` wraps [Valibot's built-in toJsonSchema](https://github.com/open-circle/valibot/blob/main/packages/to-json-schema/README.md) and adds support for additional types such as `v.bigint()`, `v.date()`, `v.set()`, and `v.map()`. Use it with tools such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator) and [Smart Coercion](/docs/plugins/smart-coercion). It accepts the same options as Valibot's `toJsonSchema`, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/valibot/src/converter.ts) for implementation details. ```ts import { OpenAPIGenerator } from '@orpc/openapi' import { ValibotToJsonSchemaConverter } from '@orpc/valibot' const generator = new OpenAPIGenerator({ converters: [new ValibotToJsonSchemaConverter()], }) ``` :::tip Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable. ```ts const converter = new ValibotToJsonSchemaConverter({ cache: true }) ``` ::: ### Reusable Schemas A common pattern is defining reusable or recursive schemas via definitions. The converter preserves them in `$defs`, which `OpenAPIGenerator` can then [hoist](/docs/openapi/specification#hoisting-defs) into `components.schemas`. For more on how definitions work in Valibot, see [Valibot JSON Schema Definitions](https://github.com/open-circle/valibot/blob/main/packages/to-json-schema/README.md#definitions). ```ts import * as v from 'valibot' const PlanetSchema = v.object({ id: v.string(), name: v.string(), }) const generator = new OpenAPIGenerator({ converters: [ new ValibotToJsonSchemaConverter({ definitions: { PlanetSchema }, }), ], }) ``` --- # Zod Integration Source: https://orpc.dev/docs/integrations/zod :::warning `@orpc/zod` requires Zod v4 or later. ::: :::info [Zod](https://zod.dev/) implements [Standard Schema](/docs/integrations/standard-schema), so procedures accept Zod schemas without any converter. The converter below is only needed by tools that consume JSON Schema, such as OpenAPI generation and Smart Coercion. ::: ## Installation ```package-install npm install @orpc/zod@beta zod ``` ## JSON Schema Converter `ZodToJsonSchemaConverter` wraps [Zod's built-in toJSONSchema](https://zod.dev/json-schema?id=ztojsonschema#ztojsonschema) and adds support for additional types such as `z.bigint()`, `z.date()`, `z.set()`, and `z.map()`. Use it with tools such as the [OpenAPI Generator](/docs/openapi/specification#openapi-generator) and [Smart Coercion](/docs/plugins/smart-coercion). It accepts the same options as Zod's `toJSONSchema`, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/zod/src/converter.ts) for implementation details. ```ts import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter } from '@orpc/zod' const generator = new OpenAPIGenerator({ converters: [new ZodToJsonSchemaConverter()], }) ``` :::tip Enable the `cache` option to reuse conversion results when the same schema instance is converted repeatedly. When enabled, repeated conversions return the same JSON schema object, so treat the results as immutable. ```ts const converter = new ZodToJsonSchemaConverter({ cache: true }) ``` ::: ### Reusable Schemas A common pattern is defining reusable schemas with `id` metadata. The converter places them in `$defs`, which `OpenAPIGenerator` then [hoists](/docs/openapi/specification#hoisting-defs) into `components.schemas`. For more on `id` and `$ref` in Zod, see [Zod JSON Schema Registries](https://zod.dev/json-schema?id=registries#registries). ```ts import * as z from 'zod' const PlanetSchema = z.object({ id: z.string(), name: z.string(), }).meta({ id: 'Planet' }) ``` ### Customizing Generated JSON Schemas `@orpc/zod` exposes registries for customizing the JSON schema generated for a given Zod schema. Registered entries are shallow merged over the generated JSON schema: `JSON_SCHEMA_REGISTRY` applies to both input and output, while `JSON_SCHEMA_INPUT_REGISTRY` and `JSON_SCHEMA_OUTPUT_REGISTRY` apply to a single direction and win on conflicting keys: ```ts import { JSON_SCHEMA_INPUT_REGISTRY, JSON_SCHEMA_OUTPUT_REGISTRY, JSON_SCHEMA_REGISTRY, } from '@orpc/zod' import * as z from 'zod' const user = z.object({ name: z.string(), age: z.string().transform(v => Number(v)), }) JSON_SCHEMA_REGISTRY.add(user, { description: 'A user', }) JSON_SCHEMA_INPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: '20' }], }) JSON_SCHEMA_OUTPUT_REGISTRY.add(user, { examples: [{ name: 'John', age: 20 }], }) ``` --- # Metadata Source: https://orpc.dev/docs/metadata ## Quickly Define Meta In most cases, use `defineMeta` to create a metadata plugin. It takes a unique name and a merge function that defines how metadata is combined across repeated calls, then returns a tuple of `[metaPlugin, getMeta]`: ```ts twoslash import { os } from '@orpc/server' declare const store: Map // ---cut--- import { defineMeta } from '@orpc/server' type CacheMeta = boolean const [cacheMeta, getCacheMeta] = defineMeta( // [!code highlight] 'cache', // [!code highlight] (incoming: CacheMeta, current) => incoming, // [!code highlight] ) // [!code highlight] const base = os.use(async ({ procedure, next, path }, input, done) => { if (getCacheMeta(procedure) !== true) { // [!code highlight] return next() } const key = path.join('/') + JSON.stringify(input) if (store.has(key)) { return done({ output: store.get(key)! }) } const result = await next() store.set(key, result.output) return result }) const cachedProcedure = base .meta(cacheMeta(true)) // [!code highlight] .handler(async () => { return 'Earth' }) ``` ## Manually Define Meta If `defineMeta` is not flexible enough, define a plugin directly with `MetaPlugin`. This gives you full control and lets the plugin infer or restrict procedure types. ```ts twoslash import { os } from '@orpc/server' import z from 'zod' // ---cut--- import type { AnySchema, ErrorMap, InferSchemaInput, InferSchemaOutput, Meta, MetaPlugin, } from '@orpc/server' interface ExampleMeta< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap > { inputExamples?: InferSchemaInput[] outputExamples?: InferSchemaOutput[] } interface ExampleMetaPlugin< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap > extends MetaPlugin { name: 'example' } function exampleMeta< TInputSchema extends AnySchema, TOutputSchema extends AnySchema, TErrorMap extends ErrorMap, >( incoming: ExampleMeta ): ExampleMetaPlugin { return { name: 'example', apply(meta) { const current = meta.example as ExampleMeta | undefined return { ...meta, example: { ...current, ...incoming, } } }, } } function getExampleMeta( procedureOrLazy: { '~orpc': { meta: Meta } } ): ExampleMeta | undefined { return procedureOrLazy['~orpc'].meta.example as ExampleMeta | undefined } const procedure = os .input(z.object({ name: z.string() })) .output(z.object({ id: z.string(), name: z.string() })) .meta(exampleMeta({ inputExamples: [{ name: 'Alice' }], // <- typesafe outputExamples: [{ id: '1', name: 'Alice' }], // <- typesafe })) .handler(async ({ input }) => { return { id: '1', name: 'Alice' } }) ``` --- # Middleware Source: https://orpc.dev/docs/middleware ## Overview ```ts twoslash import type { AnyMetaPlugin } from '@orpc/server' declare const someMeta: AnyMetaPlugin // ---cut--- import { os } from '@orpc/server' const example = os .$context<{ something?: string }>() // <- define initial context .meta(someMeta) // <- attach metadata .errors({ RATE_LIMITED: {} }) // <- attach errors .middleware(async ({ context, next, errors }) => { // <- middleware logic try { // `await` is required to catch async errors return await next({ context: { // <- Inject additional context user: { id: 1, name: 'John' } } }) } catch (error) { console.error(error) throw error } finally { // Cleanup logic after execution } }) ``` ## Initial Context Use `.$context` to declare the initial context required when middleware is applied. Learn more in the [Context Documentation](/docs/context). ## Metadata Use `.meta` to attach metadata to middleware. This metadata is applied to any procedures that use the middleware. Learn more in the [Metadata documentation](/docs/metadata). ## Typesafe Errors Use `.errors` to attach error definitions to middleware. These errors are available in the middleware and any procedures that use it. Learn more in the [Typesafe Error Handling documentation](/docs/error-handling#typesafe-errors). ## Middleware Context Middleware can be used to inject or guard the [context](/docs/context). ```ts twoslash import { ORPCError, os } from '@orpc/server' declare function auth(): { userId: number } | null // ---cut--- const setting = os .use(async ({ context, next }) => { return next({ context: { auth: await auth() // <- inject auth } }) }) .use(async ({ context, next }) => { if (!context.auth) { // <- guard auth throw new ORPCError('UNAUTHORIZED') } return next({ context: { auth: context.auth // <- override auth (now guaranteed to be non-null) } }) }) .handler(async ({ context }) => { console.log(context.auth) // <- auth is guaranteed to be non-null here }) ``` :::warning Context passed to `next` must not conflict with the existing context; it is merged at runtime. ::: ## Middleware Input Middleware can access input in type-safe manner, enabling use cases like permission checks. ```ts const canUpdate = os.middleware(async ({ context, next }, input: number) => { // Perform permission check return next() }) const ping = os .input(z.number()) .use(canUpdate) // <- input already matches middleware's expected shape .handler(async ({ input }) => { // Handler logic }) const pong = os .input(z.object({ id: z.number() })) .use(canUpdate.adaptInput(input => input.id)) // <- adapt input to match middleware's expected shape .handler(async ({ input }) => { // Handler logic }) ``` :::info You can adapt a middleware to accept a different input shape by using `.adaptInput`. ```ts const canUpdate = os.middleware(async ({ context, next }, input: number) => { return next() }) // Transform middleware to accept a new input shape const adaptedCanUpdate = canUpdate.adaptInput((input: { id: number }) => input.id) ``` ::: :::danger A middleware placed between [multiple input schemas](/docs/procedure#multiple-schemas) also receives the raw fields the remaining schemas have not validated yet. Only trust fields validated before the middleware; treat the rest as untrusted client input. ::: ## Middleware Output Middleware can also modify the output of a handler, such as implementing caching mechanisms. ```ts const cache = os.middleware(async ({ context, next, path }, input, done) => { const cacheKey = path.join('/') + JSON.stringify(input) if (db.has(cacheKey)) { return done({ output: db.get(cacheKey) }) } const result = await next({}) db.set(cacheKey, result.output) return result }) ``` ## Inline Middleware Middleware is simply a function that can be defined inline with `.use`, which is useful for simple middleware cases. ```ts const example = os .use(async ({ context, next }) => { // Execute logic before the handler return next() }) .handler(async ({ context }) => { // Handler logic }) ``` ## Combining Middleware Multiple middleware functions can be combined using `.use`. ```ts const mergedMiddleware = aMiddleware .use(async ({ next }) => next()) .use(anotherMiddleware) ``` :::info To concatenate two middlewares with different input types, use `.adaptInput` to align their inputs first. ::: --- # Migrating from tRPC Source: https://orpc.dev/docs/migrations/from-trpc :::info If you want to add oRPC features to an existing tRPC app without a full migration, see [tRPC Integration](/docs/integrations/trpc). ::: ## Core Concepts Comparison | Concept | tRPC | oRPC | | --------------------- | ---------------------------- | ------------------- | | **Router** | `t.router()` | plain object | | **Procedure** | `t.procedure` | `os` | | **Context** | `t.context()` | `os.$context()` | | **Create Middleware** | `t.middleware()` | `os.middleware()` | | **Use Middleware** | `t.procedure.use()` | `os.use()` | | **Input Validation** | `t.procedure.input(schema)` | `os.input(schema)` | | **Output Validation** | `t.procedure.output(schema)` | `os.output(schema)` | | **Error Handling** | `TRPCError` | `ORPCError` | | **Serializer** | `superjson` | built-in | :::info See [oRPC vs tRPC Comparison](/docs/comparison) for a broader comparison. ::: ## Step-by-Step Migration ### 1. Installation Remove the tRPC packages and install the oRPC replacements: ```sh npm npm uninstall @trpc/server @trpc/client @trpc/tanstack-react-query npm install @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta ``` ```sh yarn yarn remove @trpc/server @trpc/client @trpc/tanstack-react-query yarn add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta ``` ```sh pnpm pnpm remove @trpc/server @trpc/client @trpc/tanstack-react-query pnpm add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta ``` ```sh bun bun remove @trpc/server @trpc/client @trpc/tanstack-react-query bun add @orpc/server@beta @orpc/client@beta @orpc/tanstack-query@beta ``` ```sh deno deno remove npm:@trpc/server npm:@trpc/client npm:@trpc/tanstack-react-query deno add npm:@orpc/server@beta npm:@orpc/client@beta npm:@orpc/tanstack-query@beta ``` ### 2. Initialize Initialization is optional in oRPC. You can use `os` directly, but creating shared base procedures makes context and middleware easier to reuse. ```ts orpc/base.ts import { ORPCError, os } from '@orpc/server' export async function createORPCContext(opts: { headers: Headers }) { const session = await auth() return { headers: opts.headers, session, } } const o = os.$context>>() const timingMiddleware = o.middleware(async ({ next, path }) => { const start = Date.now() try { return await next() } finally { console.log(`[oRPC] ${path} took ${Date.now() - start}ms to execute`) } }) export const publicProcedure = o.use(timingMiddleware) export const protectedProcedure = publicProcedure.use(({ context, next }) => { if (!context.session?.user) { throw new ORPCError('UNAUTHORIZED') } return next({ context: { session: { ...context.session, user: context.session.user } } }) }) ``` ```ts trpc/base.ts import { initTRPC, TRPCError } from '@trpc/server' import superjson from 'superjson' export async function createTRPCContext(opts: { headers: Headers }) { const session = await auth() return { headers: opts.headers, session, } } const t = initTRPC.context().create({ transformer: superjson, }) export const createTRPCRouter = t.router const timingMiddleware = t.middleware(async ({ next, path }) => { const start = Date.now() const result = await next() const end = Date.now() console.log(`[tRPC] ${path} took ${end - start}ms to execute`) return result }) export const publicProcedure = t.procedure.use(timingMiddleware) export const protectedProcedure = t.procedure .use(timingMiddleware) .use(({ ctx, next }) => { if (!ctx.session?.user) { throw new TRPCError({ code: 'UNAUTHORIZED' }) } return next({ ctx: { session: { ...ctx.session, user: ctx.session.user }, }, }) }) ``` :::info Learn more about oRPC [Context](/docs/context) and [Middleware](/docs/middleware). ::: ### 3. Procedures oRPC does not split procedures into `.query`, `.mutation`, and `.subscription`. Use `.handler` for all procedure types. ```ts orpc/routers/planet.ts export const planetRouter = { list: publicProcedure .input(z.object({ cursor: z.number().int().default(0) })) .handler(({ input }) => { // Logic here return { planets: [ { name: 'Earth', distanceFromSun: 149.6, } ], nextCursor: input.cursor + 1, } }), create: protectedProcedure .input(z.object({ name: z.string().min(1), distanceFromSun: z.number().positive() })) .handler(async ({ context, input }) => { // Logic here }), } ``` ```ts trpc/routers/planet.ts export const planetRouter = createTRPCRouter({ list: publicProcedure .input(z.object({ cursor: z.number().int().default(0) })) .query(({ input }) => { // Logic here return { planets: [ { name: 'Earth', distanceFromSun: 149.6, } ], nextCursor: input.cursor + 1, } }), create: protectedProcedure .input(z.object({ name: z.string().min(1), distanceFromSun: z.number().positive() })) .mutation(async ({ ctx, input }) => { // Logic here }), }) ``` :::info Learn more about oRPC [Procedures](/docs/procedure). ::: ### 4. App Router The overall router structure stays similar. In oRPC, you do not wrap routers in a `.router` call. A plain object is enough. ```ts orpc/routers/index.ts import { planetRouter } from './planet' export const appRouter = { planet: planetRouter, } ``` ```ts trpc/routers/index.ts import { planetRouter } from './planet' export const appRouter = createTRPCRouter({ planet: planetRouter, }) ``` :::info Learn more about oRPC [Router](/docs/router). ::: ### 5. Error Handling Error handling is similar, but `ORPCError` takes the error code as its first argument. ```ts orpc throw new ORPCError('BAD_REQUEST', { message: 'Invalid input', data: 'some data', cause: validationError }) ``` ```ts trpc throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid input', data: 'some data', cause: validationError }) ``` :::info Learn more about oRPC [Error Handling](/docs/error-handling). ::: ### 6. Server Setup This example uses [Next.js](https://nextjs.org/). If you use another framework, see [oRPC HTTP Adapters](/docs/adapters/fetch-api). ```ts app/api/orpc/[[...rest ]/route.ts] import { RPCHandler } from '@orpc/server/fetch' const handler = new RPCHandler(appRouter, { interceptors: [ async ({ next, path }) => { try { return await next() } catch (error) { console.error(`❌ oRPC failed on ${path.join('.')}: `, error) throw error } } ] }) async function handleRequest(request: Request) { const { response } = await handler.handle(request, { prefix: '/api/orpc', context: await createORPCContext({ headers: request.headers }) }) return response ?? new Response('Not found', { status: 404 }) } export const GET = handleRequest export const POST = handleRequest ``` ```ts app/api/trpc/[trpc /route.ts] import { fetchRequestHandler } from '@trpc/server/adapters/fetch' function handler(req: Request) { return fetchRequestHandler({ endpoint: '/api/trpc', req, router: appRouter, createContext: () => createTRPCContext({ headers: req.headers }), onError: ({ path, error }) => { console.error( `❌ tRPC failed on ${path ?? ''}: ${error.message}` ) } }) } export { handler as GET, handler as POST } ``` ### 7. Client Setup Create a transport link, then use it to build a typed client. ```ts orpc/client.ts import { createORPCClient, onError } from '@orpc/client' import { RPCLink } from '@orpc/client/fetch' import { RouterClient } from '@orpc/server' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/api/orpc', interceptors: [ onError((error) => { console.error(error) }) ], }) export const client: RouterClient = createORPCClient(link) // ---------------- Usage ---------------- const { planets } = await client.planet.list({ cursor: 0 }) ``` ```ts trpc/client.ts import { createTRPCProxyClient, httpLink } from '@trpc/client' export const client = createTRPCProxyClient({ links: [ httpLink({ url: 'http://localhost:3000/api/trpc' }) ] }) // ---------------- Usage ---------------- const { planets } = await client.planet.list.query({ cursor: 0 }) ``` :::info Learn more about oRPC [Client-Side Clients](/docs/client/client-side), [Batch Plugin](/docs/plugins/batch), and [Dedupe Plugin](/docs/plugins/dedupe). ::: ### 8. TanStack Query (React) Integration The TanStack Query integration feels similar to tRPC, but it is lighter. You can use the generated `orpc` utilities directly without a React provider or custom hooks. ```ts orpc/tanstack-query.ts import { createTanstackQueryUtils } from '@orpc/tanstack-query' export const orpc = createTanstackQueryUtils(client) // ---------------- Usage in React Components ---------------- const query = useQuery(orpc.planet.list.queryOptions({ input: { cursor: 0 }, })) const infinite = useInfiniteQuery(orpc.planet.list.infiniteOptions({ input: (page: number) => ({ cursor: page }), initialPageParam: 0, getNextPageParam: lastPage => lastPage.nextCursor, })) const mutation = useMutation(orpc.planet.create.mutationOptions()) ``` ```ts trpc/tanstack-query.ts import { createTRPCContext } from '@trpc/tanstack-react-query' export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext() // ---------------- Usage in React Components ---------------- const trpc = useTRPC() const query = useQuery(trpc.planet.list.queryOptions({ cursor: 0 })) const infinite = useInfiniteQuery(trpc.planet.list.infiniteQueryOptions( {}, { initialCursor: 0, getNextPageParam: lastPage => lastPage.nextCursor, } )) const mutation = useMutation(trpc.planet.create.mutationOptions()) ``` :::info Learn more about oRPC [TanStack Query Integration](/docs/integrations/tanstack-query). ::: --- # Migrating from oRPC v1 Source: https://orpc.dev/docs/migrations/from-v1 Most of your code keeps working: many v1 names still compile through deprecated aliases, so your editor shows a strike-through hint instead of an error. :::warning[Read these first] - The RPC serializer format and the error response format changed, so v1 [RPC Link](/docs/rpc/link) and [OpenAPI Link](/docs/openapi/link) clients cannot talk to a v2 server. Deploy the upgraded server and client together. See [Wire Format Changes](#wire-format-changes). - Automatic middleware deduplication was removed. Middleware applied at both router and procedure level now runs twice. See [Middleware](#middleware). - The Batch Plugin replaced `exclude` with `filter`, which has the opposite meaning. In most cases you can simply remove `exclude`. See [Batch Plugin](#batch-plugin). - A prefix now applies to every procedure, not only the ones that define a `path`, so procedures relying on the router-derived path move under the prefix. See [Routing Moved to OpenAPI Metadata](#routing-moved-to-openapi-metadata). ::: ## Update Packages Install the v2 versions of the packages you use: ```sh v2 npm install @orpc/server@beta @orpc/client@beta ``` Some packages were renamed, merged, or promoted from experimental status: | v1 package | v2 package | | ------------------------------------------------------------------------------------ | --------------------------- | | `@orpc/openapi-client` | merged into `@orpc/openapi` | | `@orpc/react` | `@orpc/next` | | `@orpc/react-query` / `@orpc/vue-query` / `@orpc/solid-query` / `@orpc/svelte-query` | `@orpc/tanstack-query` | | `@orpc/vue-colada` | `@orpc/pinia-colada` | | `@orpc/experimental-react-swr` | `@orpc/swr` | | `@orpc/experimental-publisher` | `@orpc/publisher` | | `@orpc/experimental-publisher-durable-object` | `@orpc/cloudflare` | | `@orpc/experimental-ratelimit` | `@orpc/ratelimit` | | `@orpc/experimental-pino` | `@orpc/pino` | | `@orpc/otel` | `@orpc/opentelemetry` | | `@orpc/server/hibernation` (subpath) | `@orpc/hibernation` | :::warning The Hey API and Durable Iterator integrations no longer exist in v2. In place of Durable Iterator, use [Hibernation](/docs/integrations/hibernation) or [DurablePublisher](/docs/helpers/publisher#adapters). ::: ## Wire Format Changes Two formats changed on the wire: - The [RPC serializer](/docs/rpc/serializer) format, described in the [RPC Protocol](/docs/rpc/protocol). - The error response body, which no longer contains a `status` field, since [`status` was removed from errors](#status-removed-from-errors). Because of these changes, a v1 [RPC Link](/docs/rpc/link) or [OpenAPI Link](/docs/openapi/link) cannot talk to a v2 server (and vice versa). Deploy the upgraded server and clients together. If your [OpenAPI Handler](/docs/openapi/handler) endpoints have external consumers that expect the v1 error format, add the `status` field back with a [custom error response](/docs/openapi/handler#custom-error-response): ```ts import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' const handler = new OpenAPIHandler(router, { customErrorResponseBodyEncoder: error => ({ ...error.toJSON(), status: COMMON_ERROR_STATUS_MAP[error.code] ?? 500, }), }) ``` ## Routing Moved to OpenAPI Metadata The biggest change: `.route`, `.prefix`, `.tag`, and `.$route` no longer exist on the builder. OpenAPI routing now lives in [metadata](/docs/metadata), set with the `openapi` helper from `@orpc/openapi`. See [OpenAPI Routing](/docs/openapi/routing). ```ts v2 import { openapi } from '@orpc/openapi' const listPlanet = os .meta(openapi({ method: 'GET', path: '/planets' })) .handler(async () => []) const router = os .meta(openapi({ prefix: '/planets', tags: ['planets'] })) .router({ list: listPlanet }) ``` ```ts v1 const listPlanet = os .route({ method: 'GET', path: '/planets' }) .handler(async () => []) const router = os .prefix('/planets') .tag('planets') .router({ list: listPlanet }) ``` :::tip If you prefer the old style, the [`.route` extension](/docs/openapi/routing#route-extension) brings `.route` back (but not `.prefix`, `.tag`, or `.$route`): ```ts import '@orpc/openapi/extensions/route' // once at init time const listPlanet = os .route({ method: 'GET', path: '/planets' }) // works again .handler(async () => []) ``` ::: :::warning[Prefixes now apply to every procedure] In v1, `.prefix` only applied to procedures that defined a `path` and left the rest untouched. In v2, `prefix` applies to every procedure, including those that fall back to the [router-derived path](/docs/openapi/routing#basic-routing), so those endpoints move under the prefix: ```ts v2 const router = os.meta(openapi({ prefix: '/api/v2' })).router({ planet: { // POST /api/v2/planet/create create: os.handler(async () => ({})), }, }) ``` ```ts v1 const router = os.prefix('/api/v2').router({ planet: { // POST /planet/create create: os.handler(async () => ({})), }, }) ``` ::: The same applies to lazy routers with a prefix: ```ts v2 const router = { planet: os.meta(openapi({ prefix: '/planets' })).lazy(() => import('./planet')), } ``` ```ts v1 const router = { planet: os.prefix('/planets').lazy(() => import('./planet')), } ``` ## Procedure Builder ### `.callable` is no longer built in In v2, prefer [`call` or `createRouterClient`](/docs/client/server-side) to call procedures on the server: ```ts v2 import { call } from '@orpc/server' const getting = os.handler(async () => 'pong') const result = await call(getting, undefined, { context: {} }) ``` ```ts v1 const getting = os .handler(async () => 'pong') .callable({ context: {} }) const result = await getting() ``` :::tip If you prefer the v1 style, the [`.callable` extension](/docs/client/server-side#callable-extension) brings `.callable` back: ```ts import '@orpc/server/extensions/callable' // once at init time const getting = os .handler(async () => 'pong') .callable({ context: {} }) ``` ::: ### `.actionable` moved to `@orpc/next` Server actions are now called [server functions](/docs/integrations/next#server-functions) and live in the [Next.js Integration](/docs/integrations/next). In v2, prefer `createServerFunctionable` (or `createServerFunction`): it works exactly like `.actionable`, returning a value that is both a server function and a regular procedure. ```ts v2 'use server' import { createServerFunctionable } from '@orpc/next' import { os } from '@orpc/server' const functionable = createServerFunctionable({ context: {} }) export const getting = functionable( os.handler(async () => 'pong'), ) ``` ```ts v1 'use server' import { os } from '@orpc/server' export const getting = os .handler(async () => 'pong') .actionable({ context: {} }) ``` :::tip If you prefer the v1 style, the [`.actionable` extension](/docs/integrations/next#actionable-extension) brings `.actionable` back: ```ts import '@orpc/next/extensions/actionable' // once at init time ``` ::: The hooks were renamed too: `useServerAction` is now `useServerFunction` and `useOptimisticServerAction` is now `useOptimisticServerFunction`, both imported from `@orpc/next/hooks` (old names still work as deprecated aliases). `createFormAction` is now [`createServerFormFunction`](/docs/integrations/next#server-form-functions). ### `.input` and `.output` now stack In v1, a procedure had at most one input and one output schema. In v2, each `.input` or `.output` call adds another schema on top of the previous ones. See [Multiple Schemas](/docs/procedure#multiple-schemas). ```ts const example = os .input(z.object({ name: z.string() })) .input(z.object({ id: z.number() })) // adds a second schema .handler(async ({ input }) => {}) // input: { name: string } & { id: number } ``` :::info Stacked object input schemas compose into a single flat value, so one schema never strips the keys another schema needs. Output schemas are piped, so use loose object schemas (like `z.looseObject`) when stacking them. `.$input` was removed along with this change. ::: ### `.$config` options changed The index-based validation options were replaced by two simple flags. v2 tracks the order of middleware and validation automatically, so `dedupeLeadingMiddlewares` and the index options are gone. See [Validation Customization](/docs/recipes/validation-customization). ```ts v2 const base = os.$config({ disableInputValidation: true, disableOutputValidation: true, }) ``` ```ts v1 const base = os.$config({ initialInputValidationIndex: Number.NEGATIVE_INFINITY, initialOutputValidationIndex: Number.NaN, }) ``` ### `.$meta` replaced by meta plugins `.meta` now accepts meta plugins created with `defineMeta`, and `.$meta()` was removed. See [Metadata](/docs/metadata). ```ts v2 import { defineMeta, os } from '@orpc/server' const [cacheMeta, getCacheMeta] = defineMeta( 'cache', (incoming: boolean) => incoming, ) const base = os.use(async ({ procedure, next }) => { if (getCacheMeta(procedure) !== true) { return next() } // ... return next() }) const example = base .meta(cacheMeta(true)) .handler(async () => {}) ``` ```ts v1 import { os } from '@orpc/server' interface ORPCMetadata { cache?: boolean } const base = os .$meta({}) .use(async ({ procedure, next }) => { if (!procedure['~orpc'].meta.cache) { return next() } // ... return next() }) const example = base .meta({ cache: true }) .handler(async () => {}) ``` ## Middleware ### Renamed methods `.concat` is now `.use`, and `.mapInput` is now `.adaptInput`. The two argument form `.use(middleware, mapInput)` was removed. See [Middleware](/docs/middleware). ```ts v2 const merged = aMiddleware.use(anotherMiddleware) const example = os .input(z.object({ id: z.number() })) .use(canUpdate.adaptInput(input => input.id)) .handler(async () => {}) ``` ```ts v1 const merged = aMiddleware.concat(anotherMiddleware) const example = os .input(z.object({ id: z.number() })) .use(canUpdate, input => input.id) .handler(async () => {}) ``` ### `output` argument replaced by `done` The third middleware argument for short-circuiting with an output changed shape. See [Middleware](/docs/middleware). ```ts v2 const cacheMiddleware = os.middleware(async ({ next }, input, done) => { if (cache.has(key)) { return done({ output: cache.get(key) }) } return next() }) ``` ```ts v1 const cacheMiddleware = os.middleware(async ({ next }, input, output) => { if (cache.has(key)) { return output(cache.get(key)) } return next() }) ``` ### Automatic deduplication removed v1 automatically skipped router-level middleware that was already applied to a procedure. v2 no longer does this. :::warning Middleware applied at both the router and the procedure level now runs twice. Nothing warns you about it: an auth or logging middleware simply executes two times per request. ::: Guard shared middleware yourself with the context flag pattern from [Dedupe Middleware](/docs/recipes/dedupe-middleware): ```ts const authMiddleware = os .$context<{ user?: User, authLoaded?: boolean }>() .middleware(async ({ context, next }) => { if (context.authLoaded) { return next() } return next({ context: { user: await loadUser(), authLoaded: true }, }) }) ``` The `dedupeLeadingMiddlewares` config option was removed together with this behavior. ## Error Handling ### `status` removed from errors `ORPCError` and `.errors` definitions no longer accept a `status`. HTTP status codes are now a handler concern, configured with `errorStatusMap`. Error response bodies no longer contain the field either, see [Wire Format Changes](#wire-format-changes). See [Error Handling](/docs/error-handling) and [RPC Handler](/docs/rpc/handler). ```ts v2 import { COMMON_ERROR_STATUS_MAP } from '@orpc/server' const example = os .errors({ RATE_LIMITED: { data: z.object({ retryAfter: z.number() }) }, }) .handler(async ({ errors }) => { throw errors.RATE_LIMITED({ data: { retryAfter: 60 } }) }) const handler = new RPCHandler(router, { errorStatusMap: { ...COMMON_ERROR_STATUS_MAP, RATE_LIMITED: 429 }, }) ``` ```ts v1 const example = os .errors({ RATE_LIMITED: { status: 429, data: z.object({ retryAfter: z.number() }) }, }) .handler(async ({ errors }) => { throw errors.RATE_LIMITED({ data: { retryAfter: 60 } }) }) const handler = new RPCHandler(router) ``` ### `safe` result changed The third element of the `safe` result is now the typed error itself (or `null`) instead of a boolean, and a fourth `isSuccess` element was added. See [Client Error Handling](/docs/client/error-handling). ```ts v2 import { isDefinedError, safe } from '@orpc/client' const [error, data, definedError, isSuccess] = await safe(client.example({ id: 1 })) if (definedError) { console.log(definedError.data.retryAfter) } ``` ```ts v1 import { isDefinedError, safe } from '@orpc/client' const [error, data, isDefined] = await safe(client.example({ id: 1 })) if (error && isDefined) { console.log(error.data.retryAfter) } ``` :::tip v2 also introduces `error` factories for defining reusable typed errors outside `.errors`. See [Error Handling](/docs/error-handling). ::: ## AsyncIteratorObject (Event Iterator) The "Event Iterator" concept was renamed to [AsyncIteratorObject](/docs/async-iterator-object) (see also [AsyncIteratorObject in Client](/docs/client/async-iterator-object)). All old names still work as deprecated aliases: | v1 | v2 | | ------------------------------------ | ------------------------------------ | | `eventIterator` | `asyncIteratorObject` | | `consumeEventIterator` | `consumeAsyncIterator` | | `eventIteratorToStream` | `asyncIteratorToStream` | | `eventIteratorToUnproxiedDataStream` | `asyncIteratorToUnproxiedDataStream` | | `streamToEventIterator` | `streamToAsyncIteratorObject` | ```ts v2 import { asyncIteratorObject } from '@orpc/server' const streaming = os .output(asyncIteratorObject(z.object({ message: z.string() }))) .handler(async function* () { yield { message: 'Hello' } }) ``` ```ts v1 import { eventIterator } from '@orpc/server' const streaming = os .output(eventIterator(z.object({ message: z.string() }))) .handler(async function* () { yield { message: 'Hello' } }) ``` ### `EventPublisher` replaced by `MemoryPublisher` `EventPublisher` was removed from `@orpc/server`. Use the [Publisher Helpers](/docs/helpers/publisher) instead. Note that `publish` is now async. ```ts v2 import { MemoryPublisher } from '@orpc/publisher/memory' const publisher = new MemoryPublisher<{ 'something-updated': { id: string } }>() await publisher.publish('something-updated', { id: '1' }) ``` ```ts v1 import { EventPublisher } from '@orpc/server' const publisher = new EventPublisher<{ 'something-updated': { id: string } }>() publisher.publish('something-updated', { id: '1' }) ``` ## RPC Handler ### GET requests are rejected by default v1 shipped `StrictGetMethodPlugin` enabled by default. v2 removed that plugin (and the `strictGetMethodPluginEnabled` option) in favor of an `allowMethods` option that defaults to `['POST', 'PUT', 'PATCH', 'DELETE']`. If your client sends GET requests, allow them explicitly and add CSRF protection. See [RPC Handler](/docs/rpc/handler). ```ts v2 import { GetMethodCsrfProtectionHandlerPlugin } from '@orpc/server/plugins' import { RPC_DEFAULT_ALLOW_METHODS } from '@orpc/server/standard' const handler = new RPCHandler(router, { allowMethods: ['GET', ...RPC_DEFAULT_ALLOW_METHODS], plugins: [new GetMethodCsrfProtectionHandlerPlugin()], }) ``` ```ts v1 // GET was accepted when the procedure declared .route({ method: 'GET' }), // enforced by the default StrictGetMethodPlugin const handler = new RPCHandler(router) ``` :::tip The simplest migration is to stop sending GET instead of allowing it: remove the `method` option from your [RPC Link](/docs/rpc/link) so every call uses POST (the default), and keep the handler's default `allowMethods`. Only allow GET when you really need it, for example for HTTP caching. ::: The v2 [GET Method CSRF Protection Plugin](/docs/plugins/get-method-csrf-protection) replaces the v1 CSRF plugin pair. It checks the browser's `Sec-Fetch-*` headers, so it needs no matching link plugin and no configuration. Remove `SimpleCsrfProtectionPlugin` from your handler and `SimpleCsrfProtectionLinkPlugin` from your client; they no longer exist. ### Interceptor options renamed `rootInterceptors` is now `routingInterceptors`, and `adapterInterceptors` is now named after the adapter (for example `fetchInterceptors` on the fetch adapter). See [RPC Handler](/docs/rpc/handler). ```ts v2 const handler = new RPCHandler(router, { routingInterceptors: [/* ... */], fetchInterceptors: [/* ... */], }) ``` ```ts v1 const handler = new RPCHandler(router, { rootInterceptors: [/* ... */], adapterInterceptors: [/* ... */], }) ``` ### `filter` takes positional arguments The `filter` option on handlers (and the OpenAPI generator) receives positional arguments now. The v1 destructured form still type-checks but reads the wrong values, so update it carefully. See [RPC Handler](/docs/rpc/handler) and [OpenAPI Specification](/docs/openapi/specification). ```ts v2 const handler = new RPCHandler(router, { filter: (contract, path) => !path.includes('internal'), }) ``` ```ts v1 const handler = new RPCHandler(router, { filter: ({ contract, path }) => !path.includes('internal'), }) ``` ### Custom serializers use a `serializer` instance The `customJsonSerializers` option with numeric types was replaced by a `serializer` instance with string-keyed handlers, shared between [RPC Handler](/docs/rpc/handler) and [RPC Link](/docs/rpc/link). To override a built-in type, reuse its key (for example `date`) instead of matching a magic number. See [RPC Serializer](/docs/rpc/serializer). ```ts v2 import { RPCSerializer } from '@orpc/client' const serializer = new RPCSerializer({ handlers: { user: { condition: data => data instanceof User, serialize: data => data.toJSON(), deserialize: data => new User(data.id, data.name), }, }, }) const handler = new RPCHandler(router, { serializer }) const link = new RPCLink({ serializer }) ``` ```ts v1 import type { StandardRPCCustomJsonSerializer } from '@orpc/client/standard' const userSerializer: StandardRPCCustomJsonSerializer = { type: 21, // unique number > 20 condition: data => data instanceof User, serialize: data => data.toJSON(), deserialize: data => new User(data.id, data.name), } const handler = new RPCHandler(router, { customJsonSerializers: [userSerializer] }) const link = new RPCLink({ url: '...', customJsonSerializers: [userSerializer] }) ``` ### Event stream options nested under the response mapping The flat `eventIterator*` handler options moved under the adapter's response option, and the keep-alive default changed from 5 to 15 seconds. See [RPC Handler](/docs/rpc/handler). ```ts v2 const handler = new RPCHandler(router, { toFetchResponse: { // fetch adapter; node uses sendStandardResponse eventStream: { keepAlive: { enabled: true, interval: 15000, comment: '' }, }, }, }) ``` ```ts v1 const handler = new RPCHandler(router, { eventIteratorKeepAliveEnabled: true, eventIteratorKeepAliveInterval: 5000, eventIteratorKeepAliveComment: '', }) ``` ### WebSocket adapters unified `@orpc/server/ws` and `@orpc/server/bun-ws` were removed. A single `@orpc/server/websocket` adapter now covers `ws`, Bun, Deno, Cloudflare, and more. See [WebSocket Adapters](/docs/adapters/websocket). ```ts v2 import { RPCHandler } from '@orpc/server/websocket' wss.on('connection', (ws) => { handler.upgrade(ws, { context: {} }) }) ``` ```ts v1 import { RPCHandler } from '@orpc/server/ws' wss.on('connection', (ws) => { handler.upgrade(ws, { context: {} }) }) ``` ## Server Plugins Handler plugins were renamed with a `HandlerPlugin` suffix. Deprecated aliases exist unless noted: | v1 | v2 | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | | `CORSPlugin` | [`CORSHandlerPlugin`](/docs/plugins/cors) | | `RequestHeadersPlugin` | [`RequestHeadersHandlerPlugin`](/docs/plugins/request-headers) | | `ResponseHeadersPlugin` | [`ResponseHeadersHandlerPlugin`](/docs/plugins/response-headers) | | `BodyLimitPlugin` (from adapter subpaths) | [`RequestLimitHandlerPlugin`](/docs/plugins/request-limit) (from `@orpc/server/plugins`) | | `CompressionPlugin` (no alias) | [`RequestCompressionHandlerPlugin`](/docs/plugins/request-compression) + [`ResponseCompressionHandlerPlugin`](/docs/plugins/response-compression) | | `experimental_RethrowHandlerPlugin` | [`RethrowHandlerPlugin`](/docs/plugins/rethrow) | | `StrictGetMethodPlugin` (no alias) | removed, use `allowMethods` | ```ts v2 import { RequestLimitHandlerPlugin, ResponseCompressionHandlerPlugin, } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ new RequestLimitHandlerPlugin({ maxBodySize: 1024 * 1024 }), new ResponseCompressionHandlerPlugin(), ], }) ``` ```ts v1 import { BodyLimitPlugin, CompressionPlugin } from '@orpc/server/fetch' const handler = new RPCHandler(router, { plugins: [ new BodyLimitPlugin({ maxBodySize: 1024 * 1024 }), new CompressionPlugin(), ], }) ``` :::info When serving binary data cross-origin, allow and expose both the `Content-Disposition` and the new `Standard-Server` headers in your CORS configuration. See [Binary Data](/docs/binary-data). ::: ### CORS allows any origin by default In v1, `CORSPlugin` reflected the request origin by default. In v2, `CORSHandlerPlugin` defaults `origin` to `*`, which browsers reject for [credentialed requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#requests_with_credentials). If you rely on `credentials`, restore the v1 behavior or list your allowed origins explicitly. The `origin` and `timingOrigin` functions can now also be async. See [CORS](/docs/plugins/cors). ```ts v2 const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: origin => origin, // restore the v1 default credentials: true, }), ], }) ``` ```ts v1 const handler = new RPCHandler(router, { plugins: [ new CORSPlugin({ credentials: true, }), ], }) ``` ## Client ### `RPCLink` splits `url` into `origin` and `url` `url` is now a path prefix starting with `/`, and the origin moves to a separate `origin` option (omit it in the browser to use the current origin). See [RPC Link](/docs/rpc/link). ```ts v2 import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ origin: 'http://localhost:3000', url: '/rpc', }) ``` ```ts v1 import { RPCLink } from '@orpc/client/fetch' const link = new RPCLink({ url: 'http://localhost:3000/rpc', }) ``` ### Custom `fetch` receives a URL string The first argument of a custom `fetch` is now the URL string instead of a `Request` object. See [RPC Link](/docs/rpc/link). ```ts v2 const link = new RPCLink({ url: '/rpc', fetch: (url, init, { context }) => globalThis.fetch(url, { ...init, credentials: 'include', }), }) ``` ```ts v1 const link = new RPCLink({ url: 'http://localhost:3000/rpc', fetch: (request, init, { context }) => globalThis.fetch(request, { ...init, credentials: 'include', }), }) ``` ### Link interceptors renamed `clientInterceptors` is now `transportInterceptors`, and `adapterInterceptors` is now `fetchInterceptors` on the fetch adapter. Event stream options moved under `toFetchRequest.eventStream`, mirroring the handler-side change. See [RPC Link](/docs/rpc/link). ### Typed clients for contracts `ContractRouterClient` was renamed to `RouterContractClient` (the old name still works as a deprecated alias). `RouterClient` from `@orpc/server` is unchanged. See [Client-Side Clients](/docs/client/client-side). ```ts v2 import type { RouterContractClient } from '@orpc/contract' const client: RouterContractClient = createORPCClient(link) ``` ```ts v1 import type { ContractRouterClient } from '@orpc/contract' const client: ContractRouterClient = createORPCClient(link) ``` ### WebSocket link uses a `connect` factory Pass a factory instead of a WebSocket instance. Reconnection is now built in, so you no longer need `partysocket`. See [WebSocket Adapters](/docs/adapters/websocket). ```ts v2 import { RPCLink } from '@orpc/client/websocket' const link = new RPCLink({ connect: () => new WebSocket('ws://localhost:3000'), reconnect: { enabled: true }, }) ``` ```ts v1 import { RPCLink } from '@orpc/client/websocket' const websocket = new WebSocket('ws://localhost:3000') const link = new RPCLink({ websocket }) ``` ## Client Plugins Link plugins were renamed with a `LinkPlugin` suffix. Deprecated aliases exist for all of them: | v1 | v2 | | ---------------------- | --------------------------------------------------- | | `ClientRetryPlugin` | [`RetryLinkPlugin`](/docs/plugins/retry) | | `DedupeRequestsPlugin` | [`DedupeLinkPlugin`](/docs/plugins/dedupe) | | `RetryAfterPlugin` | [`RetryAfterLinkPlugin`](/docs/plugins/retry-after) | v2 also adds new plugins: [Timeout](/docs/plugins/timeout), [Request Compression](/docs/plugins/request-compression), and [Response Compression](/docs/plugins/response-compression). ### Batch Plugin The v2 [Batch Plugin](/docs/plugins/batch) supports every response type, including [AsyncIteratorObject](/docs/async-iterator-object) and [File/Blob](/docs/binary-data). In v1, `exclude` existed mainly to skip those unsupported responses, so in most cases you can simply remove it: ```ts v2 import { BatchLinkPlugin } from '@orpc/client/plugins' const batchPlugin = new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }], }) ``` ```ts v1 import { BatchLinkPlugin } from '@orpc/client/plugins' const batchPlugin = new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }], exclude: ({ path }) => { return ['planets/getImage', 'planets/subscribe'].includes(path.join('/')) }, }) ``` :::warning Some cases still need to skip batching, for example procedures that rely on [Hibernation](/docs/integrations/hibernation), which cannot work through batched responses. For those, use `filter`. Its meaning is inverted compared to `exclude`: `exclude` returned `true` to skip batching, while `filter` returns `false` to skip batching. Negate your predicate when migrating. ```ts const batchPlugin = new BatchLinkPlugin({ groups: [{ condition: () => true, context: {} }], filter: ({ path }) => path.join('/') !== 'chat/subscribe', // false = not batched }) ``` ::: ## Contract-First Contract types and utilities changed word order from `ContractRouter*` to `RouterContract*`. Deprecated aliases exist for all of them. See [Procedure Contract](/docs/contract/procedure) and [Router Contract](/docs/contract/router). | v1 | v2 | | ------------------------------------------------ | ------------------------------------------------------ | | `ContractRouterClient` | `RouterContractClient` | | `AnyContractRouter` | `RouterContract` | | `AnyContractProcedure` | `AnyProcedureContract` | | `InferContractRouterInputs` | `InferRouterContractInputs` | | `InferContractRouterOutputs` | `InferRouterContractOutputs` | | `minifyContractRouter` | `minifyRouterContract` | | `populateContractRouterPaths` (`@orpc/contract`) | `populateRouterContractOpenAPIPaths` (`@orpc/openapi`) | | `RequestValidationPlugin` | `RequestValidationLinkPlugin` | | `ResponseValidationPlugin` | `ResponseValidationLinkPlugin` | The implementer works the same as in v1: `implement(contract)` still supports `.$context`, `.use`, and `.middleware`. New in v2, it also accepts a [procedure config](/docs/recipes/validation-customization), either as a second argument or through `.$config`. See [Contract Implementation](/docs/contract/implementation). ```ts import { implement } from '@orpc/server' const os = implement(contract, { disableOutputValidation: true }) ``` Contract routing uses `openapi()` metadata now, as described in [Routing Moved to OpenAPI Metadata](#routing-moved-to-openapi-metadata). ## OpenAPI ### `OpenAPILink` moved into `@orpc/openapi` The `@orpc/openapi-client` package was merged into `@orpc/openapi`. Its options follow the same changes as `RPCLink` (`origin` + `url`, `transportInterceptors`, and so on). See [OpenAPI Link](/docs/openapi/link). ```ts v2 import { OpenAPILink } from '@orpc/openapi/fetch' const link = new OpenAPILink(contract, { origin: 'http://localhost:3000', url: '/api', }) ``` ```ts v1 import { OpenAPILink } from '@orpc/openapi-client/fetch' const link = new OpenAPILink(contract, { url: 'http://localhost:3000/api', }) ``` The form data helpers moved with it, from `@orpc/openapi-client/helpers` to `@orpc/openapi/helpers`. ### `OpenAPIGenerator` options restructured `schemaConverters` is now `converters`, and document fields moved under `base`. `commonSchemas` was removed: define reusable schemas natively in your schema library instead (for example `.meta({ id: 'Planet' })` in Zod), and they are hoisted into `components.schemas` automatically. See [OpenAPI Specification](/docs/openapi/specification). ```ts v2 import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter } from '@orpc/zod' const generator = new OpenAPIGenerator({ converters: [new ZodToJsonSchemaConverter()], }) const spec = await generator.generate(router, { version: '3.1.1', base: { info: { title: 'My App', version: '0.0.0' }, }, }) ``` ```ts v1 import { OpenAPIGenerator } from '@orpc/openapi' import { ZodToJsonSchemaConverter } from '@orpc/zod' const generator = new OpenAPIGenerator({ schemaConverters: [new ZodToJsonSchemaConverter()], }) const spec = await generator.generate(router, { info: { title: 'My App', version: '0.0.0' }, }) ``` Three smaller changes in the same area: - The `oo` helper (`oo.spec`) was removed. To customize the operation object, attach [`openapi({ spec })` metadata](/docs/openapi/specification#customizing-the-operation-object) directly on the procedure or router. - The `shouldHoistDef` option was replaced by [`customComponentName`](/docs/openapi/specification#hoisting-defs). Root `$defs` are now always hoisted into `components.schemas`; this option only renames them. - Documents are generated as OpenAPI 3.2.0 by default. The example above passes [`version: '3.1.1'`](/docs/openapi/specification#openapi-version) to keep the v1 behavior. ### `OpenAPIReferencePlugin` renamed and reshaped The plugin is now `OpenAPIReferenceHandlerPlugin`, and you provide the spec yourself instead of passing converters and generate options. See [OpenAPI Reference Plugin](/docs/plugins/openapi-reference). ```ts v2 import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIReferenceHandlerPlugin } from '@orpc/openapi/plugins' const generator = new OpenAPIGenerator({ converters: [new ZodToJsonSchemaConverter()], }) const handler = new OpenAPIHandler(router, { plugins: [ new OpenAPIReferenceHandlerPlugin({ provider: 'scalar', spec: () => generator.generate(router, { base: { info: { title: 'My App', version: '0.0.0' } }, }), }), ], }) ``` ```ts v1 import { OpenAPIReferencePlugin } from '@orpc/openapi/plugins' const handler = new OpenAPIHandler(router, { plugins: [ new OpenAPIReferencePlugin({ docsProvider: 'scalar', schemaConverters: [new ZodToJsonSchemaConverter()], specGenerateOptions: { info: { title: 'My App', version: '0.0.0' } }, }), ], }) ``` ### Zod integration requires Zod v4 `@orpc/zod` now supports Zod v4 only. The `@orpc/zod/zod4` subpath and the `oz` helper (`oz.file()`, `oz.openapi()`, ...) were removed. See [Zod Integration](/docs/integrations/zod). `ZodSmartCoercionPlugin` was also removed. Use the schema-agnostic [Smart Coercion Plugin](/docs/plugins/smart-coercion) instead, whose option is now named `converters`: ```ts v2 import { SmartCoercionHandlerPlugin } from '@orpc/json-schema' import { ZodToJsonSchemaConverter } from '@orpc/zod' const handler = new OpenAPIHandler(router, { plugins: [ new SmartCoercionHandlerPlugin({ converters: [new ZodToJsonSchemaConverter()], }), ], }) ``` ```ts v1 import { ZodSmartCoercionPlugin } from '@orpc/zod' const handler = new OpenAPIHandler(router, { plugins: [new ZodSmartCoercionPlugin()], }) ``` The Valibot and ArkType converters dropped their `experimental_` prefixes: `ValibotToJsonSchemaConverter` and `ArkTypeToJsonSchemaConverter`. When no converter matches, v2 falls back to [Standard Schema](/docs/integrations/standard-schema) JSON conversion instead of producing an unknown schema. ## Integrations ### TanStack Query The per-framework packages were removed in favor of `@orpc/tanstack-query`, and a few options changed. See [TanStack Query Integration](/docs/integrations/tanstack-query). ```ts v2 import { createTanstackQueryUtils } from '@orpc/tanstack-query' const orpc = createTanstackQueryUtils(client, { prefix: 'user' }) orpc.streamed.streamedOptions({ input: {} }) orpc.live.liveOptions({ input: {} }) ``` ```ts v1 import { createTanstackQueryUtils } from '@orpc/tanstack-query' const orpc = createTanstackQueryUtils(client, { path: ['user'] }) orpc.streamed.experimental_streamedOptions({ input: {} }) orpc.live.experimental_liveOptions({ input: {} }) ``` `experimental_defaults` became `scoped`, and the hydration serializer changed from `StandardRPCJsonSerializer` to `RPCJsonSerializer` (see [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer)). ### SWR and Pinia Colada `@orpc/experimental-react-swr` is now `@orpc/swr` (see [SWR Integration](/docs/integrations/swr)), and `@orpc/vue-colada` is now `@orpc/pinia-colada` with `createORPCVueColadaUtils` renamed to `createPiniaColadaUtils` (see [Pinia Colada Integration](/docs/integrations/pinia-colada)). Both switched from `path` to `prefix`, same as TanStack Query. ### NestJS Import `implement`, `ORPCError`, and `onError` from `@orpc/server` instead of `@orpc/nest`, and augment `DefaultInitialContext` instead of `ORPCGlobalContext`. See [NestJS Integration](/docs/integrations/nest). ```ts v2 import { Implement } from '@orpc/nest' import { implement, ORPCError } from '@orpc/server' declare module '@orpc/server' { interface DefaultInitialContext { request: Request } } ``` ```ts v1 import { Implement, implement, ORPCError } from '@orpc/nest' declare module '@orpc/nest' { interface ORPCGlobalContext { request: Request } } ``` ### AI SDK `@orpc/ai-sdk` now targets AI SDK v7+. `implementTool` and `createTool` became factories, and tool metadata uses the `aiSdkTool()` meta plugin. See [AI SDK Integration](/docs/integrations/ai-sdk). ```ts v2 import { createToolFactory } from '@orpc/ai-sdk' const createTool = createToolFactory({ context: {} }) const tool = createTool(someProcedure) ``` ```ts v1 import { createTool } from '@orpc/ai-sdk' const tool = createTool(someProcedure, { context: {} }) ``` ### Hibernation The `@orpc/server/hibernation` subpath became the `@orpc/hibernation` package. `HibernationPlugin` is now `HibernationHandlerPlugin`, `HibernationEventIterator` is now `HibernationAsyncIteratorClass` (aliases kept), `encodeHibernationRPCEvent` is now async, and the `'done'` event was renamed to `'close'`. See [Hibernation Integration](/docs/integrations/hibernation). ### Logging and tracing `@orpc/experimental-pino` is now `@orpc/pino`, with `LoggingHandlerPlugin` renamed to `PinoHandlerPlugin` (see [Pino Integration](/docs/integrations/pino)). `@orpc/otel` is now `@orpc/opentelemetry`, and context propagation works out of the box (see [OpenTelemetry Integration](/docs/integrations/opentelemetry)). ## Helpers Base64Url, Cookie, Encryption, and Signing helpers are unchanged in `@orpc/server/helpers`. ### Publisher The package is now `@orpc/publisher`. The resume option was restructured, `customJsonSerializers` became a `serializer` instance (see [RPC JSON Serializer](/docs/rpc/serializer#rpc-json-serializer)), and the Redis adapter switched from `ioredis` to `node-redis`. See [Publisher Helpers](/docs/helpers/publisher). ```ts v2 import { RedisPublisher } from '@orpc/publisher/redis' const publisher = new RedisPublisher(client, { subscriber, resume: { enabled: true, seconds: 300 }, }) ``` ```ts v1 import { IORedisPublisher } from '@orpc/experimental-publisher/ioredis' const publisher = new IORedisPublisher({ commander, listener, resumeRetentionSeconds: 300, }) ``` The Durable Object adapter moved to `@orpc/cloudflare`, with `PublisherDurableObject` renamed to `DurablePublisherObject`. ### Rate Limit The package is now `@orpc/ratelimit`. Watch the casing change from `Ratelimiter` to `RateLimiter` in every class name, and the middleware helper rename. See [Rate Limit Helpers](/docs/helpers/ratelimit). ```ts v2 import { ratelimit } from '@orpc/ratelimit' import { MemoryRateLimiter } from '@orpc/ratelimit/memory' const limiter = new MemoryRateLimiter({ maxRequests: 10, window: 60_000 }) const example = os .use(ratelimit({ limiter: () => limiter, key: ({ context }) => `user:${context.user.id}`, })) .handler(async () => {}) ``` ```ts v1 import { createRatelimitMiddleware } from '@orpc/experimental-ratelimit' import { MemoryRatelimiter } from '@orpc/experimental-ratelimit/memory' const limiter = new MemoryRatelimiter({ maxRequests: 10, window: 60_000 }) const example = os .use(createRatelimitMiddleware({ limiter: () => limiter, key: ({ context }) => `user:${context.user.id}`, })) .handler(async () => {}) ``` The Cloudflare rate limiter moved to `@orpc/cloudflare`. ## Deprecated Alias Cheat Sheet These renames still compile through deprecated aliases, so you can migrate them gradually: | v1 name | v2 name | Package | | ---------------------------- | ----------------------------- | -------------------------------- | | `InferClientErrorUnion` | `InferClientError` | `@orpc/client` | | `ClientPromiseResult` | `PromiseWithError` | `@orpc/client` | | `eventIterator` | `asyncIteratorObject` | `@orpc/server`, `@orpc/contract` | | `consumeEventIterator` | `consumeAsyncIterator` | `@orpc/client` | | `InferRouterCurrentContexts` | `InferRouterFinalContexts` | `@orpc/server` | | `CORSPlugin` | `CORSHandlerPlugin` | `@orpc/server/plugins` | | `BodyLimitPlugin` | `RequestLimitHandlerPlugin` | `@orpc/server/plugins` | | `ClientRetryPlugin` | `RetryLinkPlugin` | `@orpc/client/plugins` | | `DedupeRequestsPlugin` | `DedupeLinkPlugin` | `@orpc/client/plugins` | | `RetryAfterPlugin` | `RetryAfterLinkPlugin` | `@orpc/client/plugins` | | `AnyContractRouter` | `RouterContract` | `@orpc/contract` | | `ContractRouterClient` | `RouterContractClient` | `@orpc/contract` | | `minifyContractRouter` | `minifyRouterContract` | `@orpc/contract` | | `useServerAction` | `useServerFunction` | `@orpc/next/hooks` | | `useOptimisticServerAction` | `useOptimisticServerFunction` | `@orpc/next/hooks` | | `createFormAction` | `createServerFormFunction` | `@orpc/next` | | `createORPCVueColadaUtils` | `createPiniaColadaUtils` | `@orpc/pinia-colada` | If anything is missing from this guide, check the corresponding page in the v2 docs, or open an issue on [GitHub](https://github.com/middleapi/orpc/issues). --- # Bracket Notation Source: https://orpc.dev/docs/openapi/bracket-notation [OpenAPI Serializer](/docs/openapi/serializer), [OpenAPI Handler](/docs/openapi/handler), and [OpenAPI Link](/docs/openapi/link) use bracket notation whenever nested data must be represented outside plain JSON. ## Rules 1. **Repeated keys become arrays.** ``` color=red&color=blue -> { color: ['red', 'blue'] } ``` 2. **Append `[]` to push into an array.** ``` color[]=red&color[]=blue -> { color: ['red', 'blue'] } ``` 3. **Append `[number]` to target an explicit array index.** ``` color[0]=red&color[2]=blue -> { color: ['red', , 'blue'] } ``` ::: info Missing indexes create sparse arrays. Explicit indexes greater than `999` are treated as object keys by default to avoid huge sparse arrays during deserialization. To change that limit, configure `maxExplicitDeserializingArrayIndex`: ```ts const serializer = new OpenAPISerializer({ bracketNotation: { maxExplicitDeserializingArrayIndex: 1999, } }) ``` ::: 4. **Append `[key]` to target an object property.** ``` color[red]=true&color[blue]=false -> { color: { red: 'true', blue: 'false' } } ``` ## Limitations Bracket notation is designed to express structured data in constrained environments, so it has a few unavoidable limitations: - Cannot represent empty structures like empty objects `{}` or empty arrays `[]`. - Cannot represent an array at the root level. For example, `0=red&1=blue` becomes `{ 0: 'red', 1: 'blue' }`, not `['red', 'blue']`. - Cannot represent objects whose keys are all numbers, because they can be mistaken for array indexes. - Cannot reliably represent keys that contain `[` or `]`. :::info If bracket notation is used in query strings or form data, it also inherits the limitations of those formats. For example, values are always strings or files, and `null` or `undefined` cannot be represented. ::: ## Examples ### URL Query ```bash curl 'http://example.com/api/example?name[first]=John&name[last]=Doe' ``` This query is parsed as: ```json { "name": { "first": "John", "last": "Doe" } } ``` ### Form Data ```bash curl -X POST http://example.com/api/example \ -F 'name[first]=John' \ -F 'name[last]=Doe' ``` This form data is parsed as: ```json { "name": { "first": "John", "last": "Doe" } } ``` ### Complex Example ```bash curl -X POST http://example.com/api/example \ -F 'data[names][0][first]=John1' \ -F 'data[names][0][last]=Doe1' \ -F 'data[names][1][first]=John2' \ -F 'data[names][1][last]=Doe2' \ -F 'data[ages][0]=18' \ -F 'data[ages][2]=25' \ -F 'data[files][]=@/path/to/file1' \ -F 'data[files][]=@/path/to/file2' ``` This form data is parsed as: ```json { "data": { "names": [ { "first": "John1", "last": "Doe1" }, { "first": "John2", "last": "Doe2" } ], "ages": ["18", "", "25"], "files": ["", ""] } } ``` ## Learn More The bracket notation is a small, self-contained module, making it easy to understand. To explore its behavior in detail, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/bracket-notation.ts). --- # Expanding Type Support for OpenAPI Link Source: https://orpc.dev/docs/openapi/expanding-type-support-for-link Because of [OpenAPI Serializer limitations](/docs/openapi/serializer#limitations), values like `Date` and `bigint` are received by the client in JSON-friendly form. You can convert them back to native types on the client with either [Response Validation Plugin](/docs/plugins/response-validation) or [Smart Coercion Plugin](/docs/plugins/smart-coercion), but only under the conditions described below. ## Choose a Plugin - Use [Response Validation Plugin](/docs/plugins/response-validation) when you want manual control over coercion logic and can define explicit coercion rules in your schemas. - Use [Smart Coercion Plugin](/docs/plugins/smart-coercion) when you want automatic coercion based on schema instead of defining coercion logic yourself. :::warning These plugins can only restore types that the [OpenAPI Serializer](/docs/openapi/serializer) can represent. If you need additional types, extend the serializer first. Nested `Blob` and `File` values are still limited by [Bracket Notation](/docs/openapi/bracket-notation#limitations). ::: ## Use Response Validation Plugin Use [Response Validation Plugin](/docs/plugins/response-validation) when you want to manually control how values are converted back to native types. The coercion rules live in your contract schemas, so the behavior stays explicit and predictable. ```ts const contract = oc.output(z.object({ date: z.coerce.date(), bigint: z.coerce.bigint(), })) const procedure = implement(contract).handler(() => ({ date: new Date(), bigint: 123n, })) ``` The server still returns JSON-friendly data: ```ts const rawOutput = { date: '2025-09-01T07:24:39.000Z', bigint: '123', } ``` With `ResponseValidationLinkPlugin`, the client validates that response and applies your schema coercion before your code uses it. ```ts const output = { date: new Date('2025-09-01T07:24:39.000Z'), bigint: 123n, } ``` ### Setup Add the plugin to your link, then remove the `JsonifiedClient` wrapper from the client type. ```ts import type { RouterContractClient } from '@orpc/contract' import { ResponseValidationLinkPlugin } from '@orpc/contract/plugins' const link = new OpenAPILink(contract, { plugins: [ new ResponseValidationLinkPlugin(contract), // [!code ++] ], }) const client: JsonifiedClient> = createORPCClient(link) // [!code --] const client: RouterContractClient = createORPCClient(link) // [!code ++] ``` ## Use Smart Coercion Plugin Use [Smart Coercion Plugin](/docs/plugins/smart-coercion) when you want the client to coerce values automatically from schema instead of adding coercion logic to each schema manually. ```ts import type { RouterContractClient } from '@orpc/contract' import { SmartCoercionLinkPlugin } from '@orpc/json-schema' const link = new OpenAPILink(contract, { plugins: [ new SmartCoercionLinkPlugin(contract), // [!code ++] ], }) const client: JsonifiedClient> = createORPCClient(link) // [!code --] const client: RouterContractClient = createORPCClient(link) // [!code ++] ``` --- # OpenAPI Handler Source: https://orpc.dev/docs/openapi/handler ## Overview ```ts const handler = new OpenAPIHandler(router, { interceptors: [ async ({ next, path }) => { console.time(path.join('.')) try { return await next() } catch (err) { console.error(`${path.join('.')}:`, err) throw err } finally { console.timeEnd(path.join('.')) } } ], plugins: [ new CORSHandlerPlugin() ], }) ``` :::info The actual usage of `OpenAPIHandler` depends on the adapter you use. For example, when using the fetch adapter, the handler is used like this: ```ts export async function fetch(request: Request) { const { matched, response } = await handler.handle(request, { prefix: '/api', context: {} // <- provide initial context if needed }) if (matched) { return response } return new Response('Not Found', { status: 404 }) } ``` ::: :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Interceptors Interceptors let you observe or change different stages of an OpenAPI request. Common use cases include logging, error handling, and metrics. ### Routing Interceptors Routing interceptors run on every request before routing. Use them when you need to handle all requests, including requests that do not match a procedure. ```ts const handler = new OpenAPIHandler(router, { routingInterceptors: [ async ({ next, request, context }) => { if (condition) { return { matched: false } } const { matched, response } = await next() return { matched, response } }, ], }) ``` ### Interceptors These interceptors run only for matched requests, after routing and before error handling (but can't use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility)). Use them when you need access to the matched procedure. :::tip In most cases, `interceptors` are the best choice. They provide more context, are easier to work with, and run before error handling. ::: ```ts const handler = new OpenAPIHandler(router, { interceptors: [ async ({ next, request, procedure, context }) => { try { const response = await next() return response } catch (err) { if (err instanceof CustomError) { throw new ORPCError('CUSTOM_ERROR', { message: err.message, cause: err }) } throw err } }, async ({ next, path }) => { console.time(path.join('.')) try { const response = await next() return response } catch (err) { console.error(`${path.join('.')}:`, err) throw err } finally { console.timeEnd(path.join('.')) } }, ], }) ``` ### Client Interceptors Client interceptors run only for matched requests, after input decoding, before output encoding and can use `ORPCError` for [typesafe errors](/docs/error-handling#orpcerror-compatibility). Use them when you need access to the procedure, input, and output. ```ts const handler = new OpenAPIHandler(router, { clientInterceptors: [ async ({ next, input, context, procedure }) => { const output = await next() return output }, ], }) ``` ### Adapter Interceptors Some `OpenAPIHandler` implementations, such as fetch or node adapters, also support adapter interceptors. These run before [Routing Interceptors](#routing-interceptors) and let you work with the adapter's native request and response objects. ```ts const handler = new OpenAPIHandler(router, { fetchInterceptors: [ async ({ next, request }) => { const { matched, response } = await next() return { matched, response } }, ], }) ``` :::info This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. ::: ## Plugins Plugins package reusable interceptors. For example, [CORS Plugin](/docs/plugins/cors) adds a [routing interceptor](#routing-interceptors) to handle preflight requests and adds CORS headers to every response. ```ts const handler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin() ], }) ``` ## Custom Serializer Provide a custom serializer when you need to extend or override the default serialization behavior. For more details, see [OpenAPI Serializer](/docs/openapi/serializer). ```ts const handler = new OpenAPIHandler(router, { serializer: new OpenAPISerializer({ handlers: { // ...custom handlers }, }), }) ``` ## Filtering Procedures Use the `filter` option to exclude procedures from matching: ```ts const handler = new OpenAPIHandler(router, { filter: (contract, path) => getIsInternalMeta(contract) !== true, }) ``` ## Custom Error Response By default, `OpenAPIHandler` determines response status codes using `COMMON_ERROR_STATUS_MAP` and encodes error bodies in the ORPC error format. Use `errorStatusMap` and `customErrorResponseBodyEncoder` to customize this behavior: ```ts import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' const handler = new OpenAPIHandler(router, { /** * The status code should be in the `4xx` or `5xx` range (must be greater than or equal to `400`). */ errorStatusMap: { ...COMMON_ERROR_STATUS_MAP, CUSTOM_ERROR: 599, }, customErrorResponseBodyEncoder: (error) => { if (error.code === 'CUSTOM_ERROR') { return { customMessage: error.message, customCode: error.code, } } // fallback to default by returning null or undefined return null }, }) ``` | Error Code | HTTP Status Code | | ---------------------- | ---------------: | | BAD_REQUEST | 400 | | UNAUTHORIZED | 401 | | PAYMENT_REQUIRED | 402 | | FORBIDDEN | 403 | | NOT_FOUND | 404 | | METHOD_NOT_SUPPORTED | 405 | | NOT_ACCEPTABLE | 406 | | TIMEOUT | 408 | | CONFLICT | 409 | | GONE | 410 | | PRECONDITION_FAILED | 412 | | PAYLOAD_TOO_LARGE | 413 | | UNSUPPORTED_MEDIA_TYPE | 415 | | UNPROCESSABLE_CONTENT | 422 | | PRECONDITION_REQUIRED | 428 | | TOO_MANY_REQUESTS | 429 | | CLIENT_CLOSED_REQUEST | 499 | | INTERNAL_SERVER_ERROR | 500 | | NOT_IMPLEMENTED | 501 | | BAD_GATEWAY | 502 | | SERVICE_UNAVAILABLE | 503 | | GATEWAY_TIMEOUT | 504 | :::info If you use `OpenAPILink` with a custom server-side error format, make sure to configure [Custom Error Decoding](/docs/openapi/link#custom-error-decoding). ::: ## Event Stream Options Configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the client. Available options depend on the adapter. For example, the fetch adapter supports: ```ts const handler = new OpenAPIHandler(router, { toFetchResponse: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` ## Lifecycle The diagram below shows how a request flows through `OpenAPIHandler` and where each interceptor type runs: ```mermaid sequenceDiagram actor Client participant Adapter as Adapter (Fetch, Node, ...) participant Handler as OpenAPIHandler participant Procedure as Server-Side Procedure Client Client ->> Adapter: request Note over Adapter: adapter interceptors (e.g. fetchInterceptors) Adapter ->> Handler: standard request Note over Handler: routingInterceptors Handler ->> Handler: find matching procedure Handler -->> Adapter: if not matched Note over Handler: interceptors Handler ->> Handler: decode input Handler ->> Procedure: input, signal, lastEventId, ... Note over Procedure: clientInterceptors Procedure ->> Procedure: execute procedure Procedure ->> Handler: output or error Handler ->> Handler: encode output or error Handler ->> Adapter: standard response Adapter ->> Client: response ``` :::info The server-side procedure client follows the [Server-Side Client Lifecycle](/docs/client/server-side#lifecycle), and `clientInterceptors` behave like server-side client `interceptors`. ::: --- # OpenAPI Input and Output Mapping Source: https://orpc.dev/docs/openapi/input-and-output-mapping ## Input Mapping By default, oRPC uses `compact` mode where path parameters are merged with either query parameters or the request body, depending on the HTTP method. ```ts const searchPlanets = os .meta(openapi({ method: 'GET', path: '/planets/{id}' })) .input(z.object({ id: z.string(), q: z.string().optional(), })) .handler(async ({ input }) => { return { id: input.id, q: input.q } }) ``` For `GET /planets/earth?q=life`, the procedure receives: ```json { "id": "earth", "q": "life" } ``` :::info Some requests cannot be merged into a single object. For example, `POST /planets/earth` with a non-object body cannot be merged. In that case, the input contains only the path params and the body is ignored. Use [detailed input structure](#detailed-input-structure) if you also need the body. ::: ### Detailed Input Structure In `detailed` mode, the input is an object with separate `params`, `query`, `headers`, and `body` fields. ```ts const updatePlanet = os .meta(openapi({ method: 'POST', path: '/planets/{id}', inputStructure: 'detailed', })) .input(z.object({ params: z.object({ id: z.string() }), query: z.object({ dryRun: z.coerce.boolean().optional() }).optional(), headers: z.object({ 'x-trace-id': z.string() }).optional(), body: z.object({ name: z.string() }), })) .handler(async ({ input }) => { return input }) ``` For `POST /planets/earth?dryRun=true` with header `x-trace-id: abc123` and body `{ "name": "Earth" }`, the procedure receives: ```json { "params": { "id": "earth" }, "query": { "dryRun": true }, "headers": { "x-trace-id": "abc123" }, "body": { "name": "Earth" } } ``` :::info You only need to define the fields you want to access. For example, if you only care about path params and the request body, your input schema can include just `params` and `body`. ::: ### Path Parameter Styles By default, path parameters are decoded as plain strings. Use `paramsStyles` to override how each path parameter is encoded and decoded. ```ts const getPlanets = os .meta(openapi({ method: 'GET', path: '/planets/{ids}/{filters}', paramsStyles: { ids: 'comma-delimited-array', filters: 'comma-delimited-object', }, })) .input(z.object({ ids: z.array(z.string()), filters: z.object({ type: z.string(), status: z.string(), }), })) .handler(async () => []) ``` Supported path parameter styles: | Style | Example path segment | Decoded value | | ------------------------ | ---------------------------------- | ------------------------------------------------- | | `primitive` _(default)_ | `/planets/earth` | `{ id: 'earth' }` | | `comma-delimited-array` | `/planets/earth,mars` | `{ ids: ['earth', 'mars'] }` | | `comma-delimited-object` | `/planets/type,rocky,status,known` | `{ filters: { type: 'rocky', status: 'known' } }` | :::warning When using delimited styles, do not use delimiter characters like `,` in keys or values. They can make the parameter ambiguous. ::: ### Query Styles By default, query parameters are decoded with [bracket notation](/docs/openapi/bracket-notation). Use `queryStyles` to override how each query parameter is encoded and decoded. ```ts const searchPlanets = os .meta(openapi({ method: 'GET', path: '/planets', queryStyles: { keyword: 'primitive', tags: 'comma-delimited-array', filters: 'comma-delimited-object', meta: 'json', }, })) .handler(async () => []) ``` Supported query styles: | Style | Example | Decoded value | | ------------------------ | ----------------------------------------------- | --------------------------------------------------------- | | `primitive` | `?tag=a&tag=b` | `{ tag: 'b' }` | | `array` | `?tag=a&tag=b` | `{ tag: ['a', 'b'] }` | | `comma-delimited-array` | `?tags=red,blue` | `{ tags: ['red', 'blue'] }` | | `comma-delimited-object` | `?filter=size,large,brand,nike` | `{ filter: { size: 'large', brand: 'nike' } }` | | `space-delimited-array` | `?tags=red blue` | `{ tags: ['red', 'blue'] }` | | `space-delimited-object` | `?filter=size large brand nike` | `{ filter: { size: 'large', brand: 'nike' } }` | | `pipe-delimited-array` | `?tags=red\|blue` | `{ tags: ['red', 'blue'] }` | | `pipe-delimited-object` | `?filter=size\|large\|brand\|nike` | `{ filter: { size: 'large', brand: 'nike' } }` | | `json` | `?meta={"enabled":true}` | `{ meta: { enabled: true } }` | | _default_ | `?tags[]=red&tags[]=blue&filter[status]=active` | `{ tags: ['red', 'blue'], filter: { status: 'active' } }` | :::warning When using delimited styles, do not use delimiter characters like `,`, ` `, or `|` in keys or values. They can make the parameter ambiguous. ::: ## Output Mapping By default, oRPC uses `compact` mode. The procedure's return value becomes the response body, and the status code comes from `successStatus`, which defaults to `200` (should be in the `2xx` range and must be less than `400`). ```ts const getPlanet = os .meta(openapi({ method: 'GET', path: '/planets', successStatus: 200 })) .handler(async () => { return { id: 'earth', name: 'Earth' } }) ``` ### Detailed Output Structure In `detailed` mode, return an object with the following fields: - `status`: optional success status code _(defaults to `successStatus`, should be in the `2xx` range and must be less than `400`)_ - `headers`: optional response headers in lower-case keys - `body`: optional response body ```ts const savePlanet = os .meta(openapi({ method: 'PUT', path: '/planets/{id}', outputStructure: 'detailed', successStatus: 200, })) .input(z.object({ id: z.string() })) .output(z.union([ z.object({ status: z.literal(201).meta({ description: 'Created' }), body: z.object({ id: z.string(), name: z.string() }), }), z.object({ status: z.literal(200).meta({ description: 'Updated' }), body: z.object({ id: z.string(), name: z.string() }), }), ])) .handler(async ({ input }) => { if (!isExistingPlanet(input.id)) { return { status: 201, headers: { 'x-created': 'true' }, body: { id: 'earth', name: 'Earth' }, } } return { body: { id: 'earth', name: 'Earth' }, } }) ``` ## Body Hints The body parser normally uses `Content-Type`, `Content-Length`, `Content-Disposition`, and `Standard-Server` headers to decide how to parse the body. If that information is missing or misleading, use `requestBodyHint` to tell [OpenAPI Handler](/docs/openapi/handler) how to parse the request body. Likewise, use `responseBodyHint` to tell [OpenAPI Link](/docs/openapi/link) how to parse the response body. ```ts const uploadLargeFile = os .meta(openapi({ requestBodyHint: 'octet-stream', responseBodyHint: 'json', })) .input(z.instanceof(ReadableStream)) .handler(async ({ input }) => { for await (const chunk of input) { // process chunk } return { ok: true } }) ``` Supported body hints: | Hint | Parsed Result | | ------------------- | --------------------------------------------------------------------------------------------------- | | `json` | JSON value | | `form-data` | `FormData` decoded with [bracket notation](/docs/openapi/bracket-notation) | | `url-search-params` | `URLSearchParams` decoded with [bracket notation](/docs/openapi/bracket-notation) | | `event-stream` | [AsyncIteratorObject](/docs/async-iterator-object) | | `octet-stream` | [`ReadableStream`](/docs/binary-data#readablestreamuint8array) for streamed binary data | | `file` | `File` for binary data | | `none` | `undefined` | :::info Learn more about body hints in the [Standard Server documentation](https://github.com/middleapi/standard-server#body-types) ::: ## Metadata Merging When `openapi` is applied multiple times, `paramsStyles` and `queryStyles` are merged per parameter, and the most recent style defined for a parameter wins. `inputStructure`, `outputStructure`, `responseBodyHint`, and `requestBodyHint` are overridden by the most recent call. For the full merge behavior of every field, see [Metadata Merging](/docs/openapi/specification#metadata-merging). ```ts const router = os .meta(openapi({ inputStructure: 'detailed' })) .router({ get: os .meta(openapi({ method: 'GET', path: '/planets', inputStructure: 'compact' })) .meta(openapi({ queryStyles: { tags: 'comma-delimited-array' } })) .meta(openapi({ queryStyles: { q: 'primitive' } })) .input(z.object({ tags: z.array(z.string()), q: z.string().optional() })) .handler(async () => ([])), }) ``` These are equivalent to: ```ts const router = { get: os .meta(openapi({ method: 'GET', path: '/planets', inputStructure: 'compact', queryStyles: { tags: 'comma-delimited-array', q: 'primitive', }, })) .input(z.object({ tags: z.array(z.string()), q: z.string().optional() })) .handler(async () => ([])), } ``` :::info Metadata resets to its default behavior when set to `undefined` in subsequent calls: ```ts const example = os .meta(openapi({ queryStyles: { tags: 'comma-delimited-array' } })) .meta(openapi({ queryStyles: undefined })) ``` In this example, the final `queryStyles` is `undefined`, so query parameters are parsed with the default bracket notation. ::: --- # OpenAPI Link Source: https://orpc.dev/docs/openapi/link ## Overview ```ts const link = new OpenAPILink(contract, { origin: 'https://api.example.com', url: '/api', headers: ({ context }) => ({ authorization: context?.token ? `Bearer ${context.token}` : undefined, }), interceptors: [ async ({ next, path }) => { console.time(path.join('.')) try { return await next() } finally { console.timeEnd(path.join('.')) } }, ], plugins: [ new RetryAfterLinkPlugin(), ], fetch: (request, init) => { // <- only available in fetch adapter return globalThis.fetch(request, { ...init, credentials: 'include', // Include cookies on cross-origin requests }) }, }) ``` :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Typesafe Clients After you create an `OpenAPILink`, pass it to `createORPCClient` to build a typesafe client for either a [contract](/docs/contract/router) or a [router](/docs/router): ```ts import { createORPCClient } from '@orpc/client' import { RouterContractClient } from '@orpc/contract' import { JsonifiedClient } from '@orpc/openapi' import { RouterClient } from '@orpc/server' // if you are following contract-first approach const contractClient: JsonifiedClient> = createORPCClient(link) // if you are following normal approach const routerClient: JsonifiedClient> = createORPCClient(link) ``` :::info `JsonifiedClient` is required because of [OpenAPI serializer limitations](/docs/openapi/serializer#limitations). If you want to avoid `JsonifiedClient`, see [Expanding Type Support for OpenAPI Link](/docs/openapi/expanding-type-support-for-link). ::: ## Client Context Client context lets you pass per-call values, such as auth tokens or cache hints. This context is available in link options, interceptors, plugins, and other extensibility points. ```ts type ClientContext = { token?: string } const link = new OpenAPILink(contract, { headers: ({ context }) => ({ authorization: context?.token ? `Bearer ${context.token}` : undefined, }), }) ``` :::info Pass `ClientContext` when creating the client, then provide context on each call as needed: ```ts // if you are using the contract-first approach const client: RouterContractClient = createORPCClient(link) // if you are using the standard approach const client: RouterClient = createORPCClient(link) const output = await client.someProcedure(input, { context: { token: 'abc123', }, }) ``` ::: ## URL and Header Options Use `origin`, `url`, and `headers` to control request destination and headers. - `origin`: Server protocol and domain. Omit in the browser to use the current origin. - `url`: Usually a path prefix like `/api`. May include query params that are added to every request. - `headers`: Headers sent with every request, such as auth or trace IDs. Keys should be lowercase. ```ts const link = new OpenAPILink(contract, { origin: 'https://api.example.com', url: '/api?v=2', headers: { authorization: `Bearer ${getAuthToken()}`, }, }) ``` :::info Each option can also be a function to dynamically customize values per request. For example, routing to a different `origin` based on the procedure path, or injecting headers from client context: ```ts const link = new OpenAPILink(contract, { origin: ({ path, context }) => { if (path[0] === 'internal') { return 'https://internal.example.com' } return 'https://api.example.com' }, headers: ({ context }) => ({ authorization: context?.token ? `Bearer ${context.token}` : undefined, }), }) ``` ::: ## Interceptors Interceptors let you observe or customize different stages of an OpenAPI call. Common use cases include logging, retries, auth, batching, and transport customization. ### Interceptors Interceptors run around the entire call, including input encoding, transport, and response decoding. Use them when you need access to the path, input, output, or error. ```ts const link = new OpenAPILink(contract, { interceptors: [ async ({ next, path, input }) => { console.time(path.join('.')) try { const output = await next() return output } catch (err) { console.error(`${path.join('.')}:`, err) throw err } finally { console.timeEnd(path.join('.')) } }, ], }) ``` ### Transport Interceptors Interceptors run after input encoding and before response decoding. Use them to inspect or rewrite the request. ```ts const link = new OpenAPILink(contract, { transportInterceptors: [ async (options) => { const response = await options.next({ ...options, request: { ...options.request, headers: { ...options.request.headers, 'x-request-id': crypto.randomUUID(), }, }, }) return response }, ], }) ``` ### Adapter Interceptors Some `OpenAPILink` implementations also support adapter-specific interceptors. The fetch adapter exposes `fetchInterceptors`, which run right before `fetch` and give you access to the final `url` and `RequestInit`. ```ts const link = new OpenAPILink(contract, { fetchInterceptors: [ async (options) => { const response = await options.next({ ...options, init: { ...options.init, credentials: 'include', }, }) return response }, ], }) ``` :::info This example uses the fetch adapter. For other adapters, refer to their JSDoc or adapter-specific documentation. ::: ## Plugins Plugins package reusable interceptors. For example, [Retry After Plugin](/docs/plugins/retry-after) adds retry behavior based on the `retry-after` response header. ```ts const link = new OpenAPILink(contract, { plugins: [ new RetryAfterLinkPlugin(), ], }) ``` ## Custom Serializer Provide a custom serializer when you need to extend or override the default serialization behavior. For more details, see [OpenAPI Serializer](/docs/openapi/serializer). ```ts const link = new OpenAPILink(contract, { serializer: new OpenAPISerializer({ handlers: { // ...custom handlers }, }), }) ``` ## Custom Error Decoding If your server returns error responses that don't match oRPC's expected format, use `customErrorResponseBodyDecoder` to customize the decoding logic. This works together with [Custom Error Response](/docs/openapi/handler#custom-error-response) on the server. ```ts const link = new OpenAPILink(contract, { customErrorResponseBodyDecoder: (body, response) => { if (response.status === 422 && typeof body === 'object' && body && 'detail' in body) { return new ORPCError('BAD_REQUEST', { message: String(body.detail), }) } // fallback to default error decoding logic by returning null or undefined return null }, }) ``` ## Malformed Responses When `OpenAPILink` cannot decode a response, for example when a proxy or gateway answers instead of your handler, it produces an `ORPCError` with code `MALFORMED_ORPC_RESPONSE` and a message inferred from the body or status. Its `cause` is a `MalformedResponseError` carrying the resolved response: ```ts import { MalformedResponseError, ORPCError, onError } from '@orpc/client' const link = new OpenAPILink(contract, { interceptors: [ onError((error) => { if (error instanceof ORPCError && error.cause instanceof MalformedResponseError) { console.error('Malformed response:', error.cause.response.status, error.cause.response.body) } }), ], }) ``` ## Event Stream Options Configure how an [AsyncIteratorObject](/docs/async-iterator-object) is streamed to the server. Available options depend on the adapter. For example, the fetch adapter supports: ```ts const link = new OpenAPILink(contract, { toFetchRequest: { eventStream: { initialComment: { /** * If true, an initial comment is sent immediately upon stream start to flush headers. * This allows the receiving side to establish the connection without waiting for the first event. * * @default true */ enabled: true, /** * The content of the initial comment sent upon stream start. Must not include newline characters. * * @default '' */ comment: '', }, keepAlive: { /** * If true, a ping comment is sent periodically to keep the connection alive. * * @default true */ enabled: true, /** * Interval (in milliseconds) between ping comments sent after the last event. * * @default 15000 */ interval: 15000, /** * The content of the ping comment. Must not include newline characters. * * @default '' */ comment: '', }, /** * If true, a `close` event is sent even when the iterator completes with `undefined`. * When the iterator returns a value, a `close` event is always emitted regardless of this setting. * * @default true */ emptyCloseEventEnabled: true, }, }, }) ``` ## Lifecycle The diagram below shows how a call flows through `OpenAPILink` and where each interceptor type runs: ```mermaid sequenceDiagram actor Caller participant L as OpenAPILink participant Transport as Transport (Fetch, ...) participant Server Caller ->> L: path, input, signal, lastEventId, ... Note over L: interceptors L ->> L: encode request Note over L: transportInterceptors L ->> Transport: standard request Note over Transport: adapter interceptors (e.g. fetchInterceptors) Transport ->> Server: request Server ->> Transport: response Transport ->> L: standard response L ->> L: decode response L ->> Caller: output or error ``` --- # OpenAPI Link Without Runtime Imports Source: https://orpc.dev/docs/openapi/link-without-runtime-imports [OpenAPI Link](/docs/openapi/link) needs a contract at runtime to know each procedure's method and path. With [RPC Link](/docs/rpc/link) a type import is enough, but with OpenAPI Link you must either maintain a [contract](/docs/contract/router) or import your [router](/docs/router), which pulls server code into the client bundle. [Safely Importing Router on the Client](/docs/contract/router#safely-importing-router-on-the-client) avoids that by exporting a minified contract to a JSON file, but you must regenerate the file every time the router changes. Macros remove that manual step. A macro is a function your bundler runs at build time, replacing the call with its return value. [Bun supports macros natively](https://bun.com/docs/bundler/macros), and [unplugin-macros](https://github.com/unplugin/unplugin-macros) brings the same syntax to Vite, Rollup, webpack, esbuild, and Rspack. With a macro that returns the minified contract, every build embeds an up-to-date contract in the client bundle, and server code never leaves the server. ## Setup If you bundle with Bun, macros work out of the box. For other bundlers, install [unplugin-macros](https://github.com/unplugin/unplugin-macros) and register it. For example, with Vite: ```ts vite.config.ts import Macros from 'unplugin-macros/vite' import { defineConfig } from 'vite' export default defineConfig({ plugins: [Macros()], }) ``` ## Export the Contract from a Macro Define a function that derives the minified contract from your router: ```ts contract.ts import { minifyRouterContract, RouterContract } from '@orpc/contract' import { unlazyRouter } from '@orpc/server' import { router } from './router' export async function getMinifiedContract(): Promise { return minifyRouterContract(await unlazyRouter(router)) } ``` - `unlazyRouter` resolves any [lazy routers](/docs/router#lazy-router) so the whole router can be minified. - `minifyRouterContract` preserves only the metadata the client needs; schemas and handlers are stripped out. ## Create the Link Import the function with the `{ type: 'macro' }` attribute and pass its result to `OpenAPILink`: ```ts import type { JsonifiedClient } from '@orpc/openapi' import type { RouterClient } from '@orpc/server' import type { router } from './router' import { createORPCClient } from '@orpc/client' import { OpenAPILink } from '@orpc/openapi/fetch' import { getMinifiedContract } from './contract.ts' with { type: 'macro' } // [!code highlight] const link = new OpenAPILink(await getMinifiedContract(), { origin: 'https://api.example.com', url: '/api', }) const client: JsonifiedClient> = createORPCClient(link) ``` The `router` import is type-only, so it is erased at compile time and never reaches the bundle. The bundler calls `getMinifiedContract` at build time and inlines the result, so the bundle contains only plain JSON data: ```js const link = new OpenAPILink({ planet: { find: { '~orpc': { errorMap: {}, meta: { '~openapi': { method: 'GET', path: '/planets/{id}' } } } }, }, // ... }) ``` :::info unplugin-macros resolves the macro module with Node's module rules, so the relative import must include the real `.ts` extension. Bun works with or without it. Enable `allowImportingTsExtensions` in your `tsconfig.json` if TypeScript rejects the extension. ::: --- # OpenAPI Routing Source: https://orpc.dev/docs/openapi/routing ## Basic Routing If you do not set OpenAPI routing metadata, a procedure is exposed as a `POST` endpoint whose path is derived from the router structure. For example: ```ts twoslash import { os } from '@orpc/server' // ---cut--- import { openapi } from '@orpc/openapi' const router = { planet: { list: os .meta(openapi({ method: 'GET', path: '/planets' })) .handler(async () => [{ id: 'earth', name: 'Earth' }]), create: os .handler(async () => ({})), } } ``` In this example, `list` is exposed as `GET /planets` because it overrides the default method and path. `create` keeps the default behavior, so it is exposed as `POST /planet/create`. ## Path Parameters To define a path parameter, use `{name}` in the `path` and add the same field as a required key in the input schema: ```ts import { z } from 'zod' const getPlanet = os .meta(openapi({ method: 'GET', path: '/planets/{id}' })) .input(z.object({ id: z.string() })) ``` For catch-all path segments that may include `/`, use `{+name}`: ```ts const getFile = os .meta(openapi({ method: 'GET', path: '/files/{+path}' })) .input(z.object({ path: z.string() })) ``` :::info To customize path parameter encoding and decoding, see [Path Parameter Styles](/docs/openapi/input-and-output-mapping#path-parameter-styles). ::: ## Prefixes Define `prefix` to prepend a path to a procedure, or an entire router: ```ts const planetBuilder = os.meta(openapi({ prefix: '/planets' })) const listPlanets = planetBuilder .meta(openapi({ method: 'GET', path: '/' })) .handler(async () => [{ id: 'earth', name: 'Earth' }]) const createPlanet = planetBuilder .handler(async () => ({})) const router = os.meta(openapi({ prefix: '/api/v2' })).router({ planet: { list: listPlanets, create: createPlanet, }, }) ``` In this example, `listPlanets` is exposed as `GET /api/v2/planets/`. `createPlanet` is exposed as `POST /api/v2/planets/planet/create`. ### Path Parameters in Prefixes Prefixes can also include path parameters, but they must be defined as required fields in the input schema. ```ts const base = os .meta(openapi({ prefix: '/{workspaceId}' })) .input(z.object({ workspaceId: z.string() })) .use(({ next }, { workspaceId }) => { console.log('Workspace ID:', workspaceId) return next() }) const procedure = base .meta(openapi({ method: 'GET', path: '/planets/{id}' })) .input(z.object({ id: z.string() })) .handler(async ({ input }) => { console.log('Workspace ID:', input.workspaceId) console.log('Planet ID:', input.id) }) ``` ## Lazy Router When using a [lazy router](/docs/router#lazy-router), define a `prefix` so lazy loading is triggered only for relevant requests: ```ts const router = { project: os .meta(openapi({ prefix: '/projects' })) .lazy(() => import('./project')), } ``` ## Metadata Merging When `openapi` is applied multiple times, `prefix` values are concatenated in definition order, while `method`, `path`, and `successStatus` are overridden by the most recent call. For the full merge behavior of every field, see [Metadata Merging](/docs/openapi/specification#metadata-merging). ```ts const router = os .meta(openapi({ prefix: '/api/v2' })) .router({ get: os .meta(openapi({ prefix: '/planets' })) .meta(openapi({ method: 'GET', path: '/planets/{id}' })) .meta(openapi({ path: '/{id}' })) .input(z.object({ id: z.string() })) .handler(async () => ({})), }) ``` These calls are equivalent to: ```ts const router = { get: os .meta(openapi({ prefix: '/api/v2/planets', method: 'GET', path: '/{id}', })) .handler(async () => ({})), } ``` :::info Metadata resets to its default behavior when set to `undefined` in subsequent calls: ```ts const example = os .meta(openapi({ prefix: '/api/v2' })) .meta(openapi({ prefix: undefined })) ``` In this example, the final `prefix` is `undefined`, so no prefix is applied to `example`. ::: ## Shorthands For common cases, use the shorthand helpers: ```ts const listPlanets = os .meta(openapi.prefix('/planets')) .meta(openapi.method('GET')) .meta(openapi.path('/')) ``` ## `.route` extension Import `@orpc/openapi/extensions/route` from a module that always runs during initialization, such as the file where you define your base builder or create your server. This adds a `.route` method to the builder, allowing you to define OpenAPI metadata directly without wrapping it in `.meta(openapi(...))`. ```ts usage const ping = base .route({ method: 'GET', path: '/ping', }) .input(z.object({ name: z.string(), })) .handler(async ({ input }) => { return `Hello ${input.name}!` }) ``` ```ts setup import '@orpc/openapi/extensions/route' import { os } from '@orpc/server' export const base = os ``` --- # Scalar (Swagger) Source: https://orpc.dev/docs/openapi/scalar :::info This guide shows a manual setup. If you want a simpler option, use the [OpenAPI Reference Plugin](/docs/plugins/openapi-reference), which serves both the API reference UI and the OpenAPI specification for you. ::: ## Basic Example This example serves the [OpenAPI specification](/docs/openapi/specification) document at `/spec.json` and renders Scalar at `/`. ```ts import { createServer } from 'node:http' import { OpenAPIGenerator } from '@orpc/openapi' import { OpenAPIHandler } from '@orpc/openapi/node' import { CORSHandlerPlugin } from '@orpc/server/plugins' import { ZodToJsonSchemaConverter } from '@orpc/zod' const openAPIHandler = new OpenAPIHandler(router, { plugins: [ new CORSHandlerPlugin(), ], }) const openAPIGenerator = new OpenAPIGenerator({ converters: [ new ZodToJsonSchemaConverter(), ], }) const server = createServer(async (req, res) => { const { matched } = await openAPIHandler.handle(req, res, { prefix: '/api', }) if (matched) { return } if (req.url === '/spec.json') { const spec = await openAPIGenerator.generate(router, { base: { info: { title: 'My Playground', version: '1.0.0', }, servers: [ { url: '/api' }, /** Use an absolute URL in production. */ ], security: [{ bearerAuth: [] }], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', }, }, }, }, }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(spec)) return } const html = ` My Client
` res.writeHead(200, { 'Content-Type': 'text/html' }) res.end(html) }) server.listen(3000, () => { console.log('Playground is available at http://localhost:3000') }) ``` Open `http://localhost:3000` to view the API reference UI. --- # OpenAPI Serializer Source: https://orpc.dev/docs/openapi/serializer ## Supported Data Types `OpenAPISerializer` supports the following types by default: | Type | Handler key | Serialized | Notes | | -------------------------------- | ----------- | ------------------ | ------------------------------------ | | **string** | | | | | **number** | | | | | **NaN** | `nan` | `null` | | | **boolean** | | | | | **null** | | | | | **undefined** | `undefined` | `null` | Ignore `undefined` properties | | **Date** | `date` | ISO String, `null` | | | **BigInt** | `bigint` | string | | | **RegExp** | `regexp` | string | | | **URL** | `url` | string | | | **Record (object)** | | | `toJSON` methods are ignored | | **Array** | | | | | **Set** | `set` | array | | | **Map** | `map` | array | | | **Blob** | | | Unsupported in `AsyncIteratorObject` | | **File** | | | Unsupported in `AsyncIteratorObject` | | **AsyncIteratorObject** | | | Only at the root level | | **`ReadableStream`** | | | Only at the root level | :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Limitations OpenAPI Serializers are designed for one-way serialization to JSON-friendly formats. For example, a `Date` is serialized to an ISO string and remains a string after deserialization unless you add custom logic or plugins. In complex cases like mixed files with other data or nested structures in query strings, OpenAPI Serializer might use bracket notation to represent nested data, which has its own limitations. See [Bracket Notation Limitations](/docs/openapi/bracket-notation#limitations) for details. :::tip If you use [OpenAPI Link](/docs/openapi/link) to connect your client and server, follow [Expanding Type Support for OpenAPI Link](/docs/openapi/expanding-type-support-for-link) to restore native types on the client. ::: ## Custom Serializers Add custom handlers with unique keys to support additional types, or reuse a built-in key to override the default behavior. ```ts twoslash class Person { constructor( public name: string, public age: number, ) {} } // ---cut--- import { OpenAPISerializer } from '@orpc/openapi' const serializer = new OpenAPISerializer({ handlers: { person: { // <- add support for Person condition: v => v instanceof Person, serialize: (v: Person) => ({ name: v.name, age: v.age }), }, date: { // <- replace the default Date handler condition: v => v instanceof Date, serialize: (v: Date) => v.getTime(), }, }, }) ``` :::info[Use a custom serializer with OpenAPIHandler and OpenAPILink] ```ts const handler = new OpenAPIHandler(router, { serializer, }) const link = new OpenAPILink(contract, { serializer, }) ``` ::: ## Serialization Format In most cases, serialized data is JSON-serializable. ```json { "name": "John", "age": 30, "createdAt": "2024-01-01T00:00:00.000Z" } ``` ### With Files If the data includes nested `Blob` or `File`, the serializer returns a `FormData` object using [Bracket Notation](/docs/openapi/bracket-notation). Non-file values are converted to strings, and `null` or `undefined` fields are omitted. ```ts const form = new FormData() form.append('name', 'Earth') form.append('thumbnail', new Blob([''], { type: 'image/png' })) form.append('images[0]', new Blob([''], { type: 'image/png' })) form.append('createdAt', '2022-01-01T00:00:00.000Z') ``` :::info `images[0]` means the first item in `images` array. ::: ### Direct File If the entire data is a single `Blob` or `File`, it can be sent as-is without wrapping in `FormData`. ```http HTTP/1.1 200 OK Content-Type: image/png Content-Disposition: attachment; filename="earth.png" Content-Length: 12345 Standard-Server: file ``` :::info If the receiver mistakenly handles this payload as a regular (non-file) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). ::: ### AsyncIteratorObject When the output is an `AsyncIteratorObject`, it is sent as a [Server-Sent Events](https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events) stream. Each event contains one serialized chunk of data. ```http HTTP/1.1 200 OK Content-Type: text/event-stream event: message data: {"name":"John","createdAt":"2024-01-01T00:00:00.000Z"} event: message data: {"name":"Jane","createdAt":"2024-01-02T00:00:00.000Z"} ``` ### `ReadableStream` A `ReadableStream` is passed through as-is and streamed as binary data. ```http HTTP/1.1 200 OK Content-Type: application/octet-stream Standard-Server: octet-stream ``` :::info If the receiver mistakenly handles this payload as a regular (non-stream) body, set the `standard-server` header to help the receiver detect the actual data type and handle it correctly. Learn more about this header in the [Standard Server Documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). ::: ## Learn More The serializer is a small, self-contained module, making it easy to understand. To explore its behavior in detail, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/openapi-serializer.ts). --- # OpenAPI Specification Source: https://orpc.dev/docs/openapi/specification ## Metadata Use `openapi` metadata to control how a procedure appears in the generated OpenAPI document: ```ts import { oc } from '@orpc/contract' import { openapi } from '@orpc/openapi' import { z } from 'zod' const getPlanet = oc .meta(openapi({ method: 'GET', path: '/planets/{id}', operationId: 'getPlanet', summary: 'Get a planet', description: 'Returns a single planet.', tags: ['planets'], successStatus: 200, successDescription: 'Planet payload', })) .input(z.object({ id: z.string(), })) .output(z.object({ id: z.string(), name: z.string(), })) ``` :::info For routing metadata, you can learn more in [OpenAPI Routing](/docs/openapi/routing). For input and output mapping metadata, see [OpenAPI Input and Output Mapping](/docs/openapi/input-and-output-mapping). ::: ### Customizing the Operation Object Use `spec` to customize the generated operation object. If `spec` is an object, it replaces the generated operation object entirely. If `spec` is a callback, it receives the final operation object and returns an extended version. The operation object always follows OpenAPI 3.2, whatever [version](#openapi-version) you generate. ```ts const getPlanet = oc .meta(openapi({ method: 'GET', path: '/planets/{id}', spec: current => ({ ...current, security: [{ bearerAuth: [] }], }), })) .input(z.object({ id: z.string() })) ``` ### Metadata Merging When `openapi` is applied multiple times, most fields, such as `method`, `path`, `operationId`, `summary`, and `description`, are overridden by the most recent call. Only the following fields are merged: - `tags` and `prefix` values are concatenated in definition order. - `paramsStyles` and `queryStyles` are merged per parameter. The most recent style defined for a parameter wins. - `spec` values are combined: two functions are chained so the most recent one receives the result of the previous one, a function combined with an object is applied to that object, and between two objects the most recent one wins. For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/openapi/src/meta.ts). ```ts const router = os .meta(openapi({ tags: ['planets'], spec: current => ({ ...current, security: [{ bearerAuth: [] }], }), })) .router({ list: os .meta(openapi({ method: 'GET', summary: 'List planets', tags: ['list'] })) .meta(openapi({ spec: { operationId: 'getPlanet', summary: 'List planets', responses: { 200: { description: 'List of planets', }, } } })) .input(z.object({ q: z.string().optional() })) .handler(async () => ([])), }) ``` These are equivalent to: ```ts const router = { list: os .meta(openapi({ method: 'GET', tags: ['planets', 'list'], summary: 'List planets', spec: { operationId: 'getPlanet', summary: 'List planets', responses: { 200: { description: 'List of planets', }, }, security: [{ bearerAuth: [] }], }, })) .input(z.object({ q: z.string().optional() })) .handler(async () => ([])), } ``` :::info Metadata resets to its default behavior when set to `undefined` in subsequent calls: ```ts const example = os .meta(openapi({ tags: ['planets'] })) .meta(openapi({ tags: undefined })) ``` In this example, the final `tags` is `undefined`, so no tags are applied to `example`. ::: ## OpenAPI Generator `OpenAPIGenerator` turns a [contract](/docs/contract/router) or a [router](/docs/router) into an OpenAPI document. ```ts import { OpenAPIGenerator } from '@orpc/openapi' const generator = new OpenAPIGenerator({ converters: [new ZodToJsonSchemaConverter()], }) const spec = await generator.generate(router, { version: '3.2.0', base: { info: { title: 'Planet API', version: '1.0.0', }, servers: [ { url: 'https://example.com/api' }, ], }, }) ``` `base` provides the OpenAPI 3.2 document fields to start from, such as `info`, `servers`, or `components`. The `openapi` field comes from `version`. ### OpenAPI Version `version` selects the OpenAPI version, `3.2.0` by default. Any `3.0.x`, `3.1.x`, or `3.2.x` value works. ```ts const spec = await generator.generate(router, { version: '3.0.4', }) ``` The document is always built as OpenAPI 3.2, so `base`, [`openapi({ spec })`](#customizing-the-operation-object), and every JSON schema follow 3.2. Older versions come from downgrading the whole document with [`@openapi-spec/downgrader`](https://github.com/middleapi/openapi-spec/blob/main/packages/downgrader/README.md), which converts what the older version can still express and removes the rest. :::warning `QUERY` operations require OpenAPI 3.2. Generating an older version from a router with a `QUERY` procedure throws. ::: ### Json Schema Converters `OpenAPIGenerator` relies on JSON Schema converters to translate your input, output, and error schemas into JSON Schemas. oRPC provides dedicated converters through the [Zod](/docs/integrations/zod), [Valibot](/docs/integrations/valibot), and [ArkType](/docs/integrations/arktype) integrations: ```ts import { ZodToJsonSchemaConverter } from '@orpc/zod' import { ValibotToJsonSchemaConverter } from '@orpc/valibot' import { ArkTypeToJsonSchemaConverter } from '@orpc/arktype' const generator = new OpenAPIGenerator({ converters: [ new ZodToJsonSchemaConverter(), new ValibotToJsonSchemaConverter(), new ArkTypeToJsonSchemaConverter(), ], }) ``` :::info When no matching converter is configured, `OpenAPIGenerator` falls back to [Standard Json Schema](https://standardschema.dev/json-schema) conversion. See [Standard Schema Integration](/docs/integrations/standard-schema) for details, including how to build your own converter. ::: ### Custom Serializer If your [OpenAPI Handler](/docs/openapi/handler#custom-serializer) uses a custom serializer, configure `OpenAPIGenerator` with the same serializer so the generated document matches the actual formats. For details, see [OpenAPI Serializer](/docs/openapi/serializer). ```ts const handler = new OpenAPIGenerator({ serializer: new OpenAPISerializer({ handlers: { // ...custom handlers }, }), }) ``` ### Filtering Procedures Use `filter` to exclude procedures from the generated document: ```ts const spec = await generator.generate(router, { filter: (_procedure, path) => !path.includes('internal'), }) ``` ### Hoisting `$defs` Root `$defs` generated by your converters are moved into `components.schemas`. Use `customComponentName` to rename them: ```ts const spec = await generator.generate(router, { customComponentName: (defName, defSchema) => `Api${defName}`, }) ``` #### Custom Error Response Schemas If your [OpenAPI Handler](/docs/openapi/handler#custom-error-response) uses custom error response formats, configure `OpenAPIGenerator` with the same logic so the generated document matches the actual error response formats. ```ts import { COMMON_ERROR_STATUS_MAP } from '@orpc/openapi' const spec = await generator.generate(router, { errorStatusMap: { ...COMMON_ERROR_STATUS_MAP, PLANET_GONE: 410, }, customErrorResponseBodySchema: (definedErrors, status) => { if (status === 410) { return { type: 'object', properties: { code: { type: 'string' }, message: { type: 'string' }, }, required: ['code', 'message'], } } // fallback to default by returning null or undefined return null }, }) ``` --- # Playgrounds Source: https://orpc.dev/docs/playgrounds ## Available Playgrounds | Environment | StackBlitz | GitHub Source | | --------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | | Bun Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/bun) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/bun) | | Cloudflare Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/cloudflare) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/cloudflare) | | NestJS Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/nest) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/nest) | | Next.js Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc/tree/main/playgrounds/next) | [View Source](https://github.com/middleapi/orpc/tree/main/playgrounds/next) | | Expo Playground | [Open in StackBlitz](https://stackblitz.com/github/middleapi/orpc-expo-playground) | [View Source](https://github.com/middleapi/orpc-expo-playground) | :::warning StackBlitz has its own limitations, so some features may not work as expected. ::: ## Local Development Prefer working locally? Clone the playground with: ```bash npx giget gh:middleapi/orpc/playgrounds/bun orpc-bun-playground npx giget gh:middleapi/orpc/playgrounds/cloudflare orpc-cloudflare-playground npx giget gh:middleapi/orpc/playgrounds/nest orpc-nest-playground npx giget gh:middleapi/orpc/playgrounds/next orpc-next-playground npx giget gh:middleapi/orpc-expo-playground orpc-expo-playground ``` Then install dependencies and start the dev server: ```bash # Install dependencies npm install # Start the development server npm run dev ``` - Visit `http://localhost:3000` to view the app. - Visit `http://localhost:3000/api` to explore the OpenAPI client. ### OpenTelemetry Collect OpenTelemetry traces with [Jaeger](https://www.jaegertracing.io/) by running this in a separate terminal: ```bash npm run jaeger ``` Then play with your app and open `http://localhost:16686` to see the traces in the Jaeger dashboard. --- # Batch Plugin Source: https://orpc.dev/docs/plugins/batch :::warning HTTP/2, HTTP/3, and later versions already support multiplexing, which allows multiple requests and responses to share a single connection. Because these protocols are now widely adopted, this plugin is often less useful than it once was. ::: ## Setup Set up batching on both the server and the client. The server plugin handles incoming batch requests, and the client plugin groups outgoing requests into batches. ```ts server.ts import { BatchHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ new BatchHandlerPlugin(), ], }) ``` ```ts client.ts import { BatchLinkPlugin } from '@orpc/client/plugins' const link = new RPCLink({ url: '/rpc', plugins: [ new BatchLinkPlugin({ groups: [ { condition: () => true, context: {}, }, ], }), ], }) ``` :::warning `BatchHandlerPlugin` detects batch requests by checking for the `orpc-batch` header. If you enable CORS, add this header to your allowlist so cross-origin batch requests are not blocked. ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['orpc-batch'], }) ``` ::: ## Response Modes By default, the plugin uses `streaming` mode. Responses are sent as soon as they are ready, so one slow request does not block the rest of the batch. If your environment does not support streaming responses, such as some serverless platforms or older browsers, switch to `buffered` mode instead. In this mode, all responses are collected and sent together. ```ts const link = new RPCLink({ url: '/rpc', plugins: [ new BatchLinkPlugin({ mode: 'buffered', groups: [ { condition: () => true, context: {}, }, ], }), ], }) ``` ### Keep-Alive Timer In **streaming** mode, long-running batch responses may remain idle for extended periods. The server plugin can send keep-alive frames to keep the connection alive. ```ts const handler = new RPCHandler(router, { plugins: [ new BatchHandlerPlugin({ keepAlive: { /** * If true, a keep-alive frame is sent periodically while the stream is idle. * * @default true */ enabled: true, /** * Interval (in milliseconds) between keep-alive frames after the last message. * * @default 15000 */ interval: 15000, }, }), ], }) ``` ## Compression A batch response might be left uncompressed by the [Response Compression Plugin](/docs/plugins/response-compression), depending on the shape it takes. On Node.js, add the [Batch Response Compression Plugin](/docs/plugins/batch-response-compression) when you know your batch responses are all compressible. It covers every one of them, without giving up streaming: ```ts import { BatchResponseCompressionHandlerPlugin } from '@orpc/node' const handler = new RPCHandler(router, { plugins: [ new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin(), ], }) ``` ## Groups Only requests in the same group are batched together. Each group also defines a context, as described in [client context](/docs/rpc/link#client-context). The following example batches requests by cache policy: ```ts interface ClientContext { cache?: RequestCache } const link = new RPCLink({ method: ({ context }) => { if (context?.cache) { return 'GET' } return 'POST' }, plugins: [ new BatchLinkPlugin({ groups: [ { condition: ({ context }) => context?.cache === 'force-cache', context: { // used for the rest of the request lifecycle cache: 'force-cache', }, }, { // Fallback for all other requests. Keep this last. condition: () => true, context: {}, }, ], }), ], fetch: (url, init, { context }) => globalThis.fetch(url, { ...init, cache: context?.cache, }), }) ``` Now, calls made with `cache = 'force-cache'` use that cache setting whether they are batched or sent individually. ## Filtering Requests Use `filter` to skip batching for specific requests before group matching runs. Requests for which `filter` returns `false` continue through the link chain individually. ```ts const link = new RPCLink({ url: '/rpc', plugins: [ new BatchLinkPlugin({ filter: ({ path }) => !path.includes('upload'), groups: [ { condition: () => true, context: {}, }, ], }), ], }) ``` ## Learn More See the [BatchHandlerPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/batch.ts) and the [BatchLinkPlugin source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/batch.ts) for implementation details. --- # Batch Response Compression Plugin Source: https://orpc.dev/docs/plugins/batch-response-compression ## Installation ```package-install npm install @orpc/node@beta ``` ## Setup A [batch](/docs/plugins/batch) frames several subresponses together, each with its own content type, so the envelope has no single content type to judge. A batch of JSON compresses about tenfold; one carrying images or other already-compressed files does not compress at all, and nothing in the envelope says which you have. The [Response Compression Plugin](/docs/plugins/response-compression) therefore leaves framed batches alone rather than guess. Use `BatchResponseCompressionHandlerPlugin` to compress them anyway. Registering it is how you state that your batches are compressible, which is usually the case when they carry JSON. It covers every successful batch response, whatever shape it takes. ```ts import { BatchResponseCompressionHandlerPlugin } from '@orpc/node' import { RPCHandler } from '@orpc/server/node' import { BatchHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ new BatchHandlerPlugin(), new BatchResponseCompressionHandlerPlugin({ /** * The compression schemes to use for batch responses. * Schemes are prioritized by their order in this array and * only applied if the client supports them. * Supported values: 'gzip' | 'deflate' | 'deflate-raw' * * @default ['gzip', 'deflate'] */ encodings: ['gzip', 'deflate'], /** * The minimum response size in bytes required to trigger compression. * Responses smaller than this threshold will not be compressed to * avoid overhead. A streaming batch response has no size until it * ends, so it is always compressed. * * @default 1024 (1KB) */ threshold: 1024, }), ], }) ``` :::warning The whole envelope is compressed, binary subresponses included. Leave this plugin off when your batches mostly carry images, video, or other already-compressed content: compressing it spends CPU for nothing and can make the response marginally larger. ::: ## Why a Node.js Plugin A streaming batch sends each response as soon as its procedure resolves. A compressor that cannot flush would hold every early response in its buffer until the slowest one finished, trading streaming for compression. The web [CompressionStream](https://developer.mozilla.org/en-US/docs/Web/API/CompressionStream) has no flush, so this plugin uses [zlib](https://nodejs.org/api/zlib.html) and ends each write with a sync flush instead. The few bytes each flush costs are what keep the batch streaming, [keep-alive frames](/docs/plugins/batch#keep-alive-timer) included. ## Client No client setup is needed. Fetch implementations advertise the encodings they accept and decompress the response as it arrives, so the batch client decodes each message the moment it lands. For a link whose transport does not decompress on its own, add the [Response Compression Link Plugin](/docs/plugins/response-compression#client). :::tip Register the [Response Compression Plugin](/docs/plugins/response-compression) alongside this one to cover everything else your handler serves. The two never compress the same response twice, whichever order they are registered in. ::: ## Learn More For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/node/src/batch-response-compression-handler-plugin.ts). --- # CORS Handler Plugin Source: https://orpc.dev/docs/plugins/cors ## Basic ```ts twoslash import { RPCHandler } from '@orpc/server/fetch' import { router } from './shared/planet' // ---cut--- import { CORSHandlerPlugin } from '@orpc/server/plugins' const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: ['https://app.example.com', 'https://admin.example.com'], allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'QUERY'], // ... }), ], }) ``` :::info The `handler` can be any supported oRPC handler, such as [RPCHandler](/docs/rpc/handler), [OpenAPIHandler](/docs/openapi/handler), or a custom one. ::: :::warning By default, `origin` is `*`, which allows any origin. The wildcard is rejected by browsers for [credentialed requests](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#requests_with_credentials), so list your allowed origins explicitly when you enable `credentials`. ::: ## Dynamic Origin The `origin` and `timingOrigin` options also accept a function (optionally async) that receives the request origin and the interceptor options, including the [handler context](/docs/context). This lets you resolve the allowed origin per request: ```ts const handler = new RPCHandler(router, { plugins: [ new CORSHandlerPlugin({ origin: async (origin, { context }) => context.tenant ? origin : null, }), ], }) ``` :::warning To better support `Blob`, `File`, and `ReadableStream` at the root level in cross-origin scenarios, extend your [CORS allowlist](https://developer.mozilla.org/en-US/docs/Glossary/CORS-safelisted_response_header) to allow clients to send and receive the `Content-Disposition` and `Standard-Server` headers. Learn more in the [Standard Server documentation](https://github.com/middleapi/standard-server#how-body-parsing-works). If you use the [CORS Plugin](/docs/plugins/cors), include them in `allowHeaders` and `exposeHeaders`: ```ts const cors = new CORSHandlerPlugin({ allowHeaders: ['Content-Disposition', 'Standard-Server'], exposeHeaders: ['Content-Disposition', 'Standard-Server'], }) ``` ::: ## Learn More For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/server/src/plugins/cors.ts). --- # Dedupe Plugin Source: https://orpc.dev/docs/plugins/dedupe ## Overview ```ts import { DedupeLinkPlugin } from '@orpc/client/plugins' const link = new RPCLink({ plugins: [ new DedupeLinkPlugin({ groups: [ { condition: () => true, context: {}, // Context used for the rest of the request lifecycle }, ], }), ], }) ``` :::info The `link` can be any supported oRPC link, such as [RPCLink](/docs/rpc/link), [OpenAPILink](/docs/openapi/link), or a custom one. ::: ## Filter By default, the plugin deduplicates `GET` and `QUERY` requests. You can customize this behavior by providing a `filter` function. ```ts const link = new RPCLink({ plugins: [ new DedupeLinkPlugin({ filter: ({ request }) => request.method === 'GET', groups: [ { condition: () => true, context: {}, }, ], }), ], }) ``` :::warning If you are using [RPC Link](/docs/rpc/link), you might need to [customize the request method](/docs/rpc/link#request-method) because it defaults to `POST`. ::: :::tip If your application does not need to run multiple mutation requests in parallel within the same [call stack](https://developer.mozilla.org/en-US/docs/Glossary/Call_stack), you can expand the filter to deduplicate **all** request types. This can also help prevent duplicate mutation requests when users click actions too quickly. ::: ## Groups Only requests in the same group are deduplicated together. Each group also defines a `context`, as described in [client context](/docs/client/client-side#client-context). The following example deduplicates requests by cache policy: ```ts interface ClientContext { cache?: RequestCache } const link = new RPCLink({ method: ({ context }) => { if (context?.cache) { return 'GET' } return 'POST' }, plugins: [ new DedupeLinkPlugin({ groups: [ { condition: ({ context }) => context?.cache === 'force-cache', context: { // used for the rest of the request lifecycle cache: 'force-cache', }, }, { // Fallback for all other requests. Keep this last. condition: () => true, context: {}, }, ], }), ], fetch: (url, init, { context }) => globalThis.fetch(url, { ...init, cache: context?.cache, }), }) ``` Now, calls made with `cache = 'force-cache'` use that cache setting whether they are deduplicated or sent individually. ## Learn More For implementation details, see the [source code](https://github.com/middleapi/orpc/blob/main/packages/client/src/plugins/dedupe.ts). --- # GET Method CSRF Protection Plugin Source: https://orpc.dev/docs/plugins/get-method-csrf-protection ## How It Works Cross-site, browsers withhold explicitly marked [`SameSite=Lax`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#samesitesamesite-value) cookies from unsafe methods such as `POST`, but still attach them to [safe methods](https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP) like `GET` on top-level navigations, per [RFC 6265bis](https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/). The plugin closes that gap by rejecting exactly those navigations with a `403`: | Request from another site | Sends `SameSite=Lax` cookies | | | --- | --- | --- | | link click, redirect, `window.open`, GET form | yes | rejected | | address bar, bookmark, link from an email or native app | yes | rejected | | `fetch`, `XMLHttpRequest` | no | allowed, CORS governs the response | | ``, `