OpenAPI
REST routes
Serve the same router over REST: route meta, the handler, inputs, outputs and groups.
On this page
@trpc12/trpc/openapi serves the same router over REST, generates an OpenAPI 3.1 document from its schemas, and calls REST endpoints with the tRPC client. RPC and REST run side by side; neither changes the other.
Route meta
The openapi() root extension adds a route meta key. A procedure without one is served at its path: GET /api/post/byId for queries, POST /api/post/create for mutations. route picks the method, path and status.
import { function openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>openapi } from '@trpc12/trpc/openapi';
import { import zz } from 'zod';
const const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t = initTrpc<{
ctx: Context;
}>(opts?: RootOptions<{}>): TrpcRoot<Context, {}, RootDef<Context, {}>>
initTrpc<{ ctx: Contextctx: Context }>().TrpcRoot<Context, {}, RootDef<Context, {}>>.concat<RootDef<{}, OpenApiMeta>>(extension: ProcedureBuilder<RootDef<{}, OpenApiMeta>>): TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
concat(function openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>openapi());
const const byId: BuildProcedure<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
... 5 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
}>, "query", {
...;
}>
byId = const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.procedure: ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>procedure
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.meta<{
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
}>(meta: {
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
};
}>): ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
meta({ route: {
readonly method: "GET";
readonly path: "/posts/{id}";
readonly tags: readonly ["posts"];
}
route: { method: "GET"method: 'GET', path: "/posts/{id}"path: '/posts/{id}', tags: readonly ["posts"]tags: ['posts'] } })
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.input<z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>> (+1 overload)
input(import zz.function object<{
id: z.ZodString;
}>(shape?: {
id: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
object({ id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }))
.ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>>.errors<{
readonly NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
};
}>(map: {
readonly NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
};
}): ProcedureBuilder<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<...>;
}>>
errors({ type NOT_FOUND: {
readonly data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>;
}
NOT_FOUND: { data: z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
data: import zz.function object<{
id: z.ZodString;
}>(shape?: {
id: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>
object({ id: z.ZodStringid: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string() }) } })
.ProcedureBuilder<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.query<Post | TrpcError<"NOT_FOUND", {
id: string;
}>>(resolver: (opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | ... 10 more ... | {
...;
}>;
}>>) => Post | TrpcError<...>): BuildProcedure<...>
query(
(opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
... 5 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
}>>
opts) =>
const db: {
post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
};
}
db.post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
}
post.find: (id: string) => Post | undefinedfind(opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
... 5 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.input: {
id: string;
}
input.id: stringid) ??
opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
... 5 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.errors: ErrorConstructors<{
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
}>
errors.type NOT_FOUND: (opts: ErrorConstructorOpts<{
id: string;
}>) => TrpcError<"NOT_FOUND", {
id: string;
}>
NOT_FOUND({ data: {
id: string;
}
data: { id: stringid: opts: ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
id: z.ZodString;
}, z.core.$strip>>, {
declared: {
NOT_FOUND: TrpcError<"NOT_FOUND", {
id: string;
}>;
};
errors: TrpcError<"NOT_FOUND", {
id: string;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
... 5 more ...;
readonly message: string;
} | ... 9 more ... | {
...;
}>;
}>>
opts.ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.input: {
id: string;
}
input.id: stringid } }),
);
const const create: BuildProcedure<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>, "mutation", {
output: Post;
errors: never;
services: never;
unmapped: never;
}>
create = const t: TrpcRoot<Context, {
route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t.TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.procedure: ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>procedure
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.meta<{
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
}>(meta: {
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
} & UndeclaredMeta<{
route?: Route | undefined;
}, {
readonly route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
};
}>): ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
meta({ route: {
readonly method: "POST";
readonly path: "/posts";
readonly successStatus: 201;
}
route: { method: "POST"method: 'POST', path: "/posts"path: '/posts', successStatus: 201successStatus: 201 } })
.ProcedureBuilder<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.input<z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>(schema: z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>): ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>> (+1 overload)
input(import zz.function object<{
title: z.ZodString;
}>(shape?: {
title: z.ZodString;
} | undefined, params?: string | {
error?: string | z.core.$ZodErrorMap<NonNullable<z.core.$ZodIssueUnrecognizedKeys | z.core.$ZodIssueInvalidType<unknown>>> | undefined;
message?: string | undefined | undefined;
} | undefined): z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>
object({ title: z.ZodStringtitle: import zz.function string(params?: string | z.core.$ZodStringParams): z.ZodString (+1 overload)string()._ZodString<$ZodStringInternals<string>>.min(minLength: number, params?: string | z.core.$ZodCheckMinLengthParams): z.ZodStringmin(1) }))
.ProcedureBuilder<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ title: ZodString; }, $strip>>>.mutation<Post>(resolver: (opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>) => Post): BuildProcedure<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>, "mutation", {
output: Post;
errors: never;
services: never;
unmapped: never;
}>
mutation((opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>
opts) => const db: {
post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
};
}
db.post: {
find: (id: string) => Post | undefined;
list: () => Post[];
create: (input: {
title: string;
author?: string | null;
}) => Post;
}
post.create: (input: {
title: string;
author?: string | null;
}) => Post
create(opts: ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, z.ZodObject<{
title: z.ZodString;
}, z.core.$strip>>>
opts.ResolverOpts<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ title: ZodString; }, $strip>>>.input: {
title: string;
}
input));route also takes summary, description, deprecated and operationId. When two paths match, the one with more leading static segments wins: /posts/latest before /posts/{id}.
spec overrides the generated operation in the document: an object replaces it, a function extends it. Later .meta({ route }) calls merge into earlier ones key by key, and spec functions compose, so a base procedure can add security and its procedures add more:
const authed = t.procedure
.use(requireUser)
.meta({ route: { spec: (op) => ({ ...op, security: [{ bearer: [] }] }) } });
const me = authed
.meta({
route: { path: '/me', spec: (op) => ({ ...op, 'x-internal': true }) },
})
.query(({ ctx }) => ctx.user);spec only changes the document (target: 'openapi'); toContract() leaves it out.
The handler
import { cors } from '@trpc12/trpc/http';
import { createOpenApiHandler, openApiReference } from '@trpc12/trpc/openapi';
const openapi = createOpenApiHandler({
router: appRouter,
createContext,
layer,
middleware: [
cors(),
openApiReference({
router: appRouter,
specGenerateOptions: { info: { title: 'API', version: '1.0.0' } },
}),
],
});
// GET /api/posts/1, GET /api/docs (Scalar), GET /api/openapi.jsonprefix defaults to /api. Like the RPC handler, it has fetch(request) and dispose(), and middleware takes Effect HTTP middleware, outermost first.
openApiReference() is middleware too, so it also works on createFetchHandler or any effect/http app. It answers GET /api/docs and GET /api/openapi.json and passes other requests through:
docsProvider: 'swagger'serves Swagger UI instead of Scalar;pathandspecPathmove the two pages.{ router, specGenerateOptions }generates the document on the first request.{ document }serves one you built, e.g. with the TypeScript generator below; it can be a function.- Without
servers, the document lists the request's origin.
Input
With the default inputStructure: 'compact', path params merge with the query string (GET) or the body into one input.
- Query strings and forms use bracket notation (
?tags[]=a&author[name]=x) and are coerced by the input's JSON Schema, so?limit=10&published=truegives a number and a boolean. - JSON bodies are taken as they are.
multipart/form-datafields can holdFiles.
inputStructure: 'detailed' passes { params, query, headers, body }, each with its own schema. outputStructure: 'detailed' lets the resolver return { status?, headers?, body }.
const update = t.procedure
.meta({ route: { method: 'PATCH', path: '/posts/{id}', inputStructure: 'detailed', outputStructure: 'detailed' } })
.input(z.object({
params: z.object({ id: z.string() }),
headers: z.object({ 'if-match': z.string() }),
body: z.object({ title: z.string() }),
}))
.mutation(({ input }) => ({ headers: { etag: '…' }, body: … }));Output and errors
- The output is the JSON body. A
BloborFileoutput is sent as the body. Promises and async iterables inside an output aren't supported over REST. - Set
response.status = 204in a resolver to send no body. - Declared errors use their declared status, or their code's:
404 { defined: true, code: 'NOT_FOUND', message, data }. The body has nostatus; the response's status is it. Invalid input is a400withissues. - Unexpected errors are masked as
500 Internal server errorunlessexposeUnexpectedErrorsis on. It defaults to on only in development and test. - Subscriptions are
text/event-streamand resume fromLast-Event-ID.
Groups and lazy subtrees
Under the openapi() extension, t.router({ meta: { route: { prefix, tags } } }, routes) groups routes for OpenAPI. Types and RPC paths don't change, and it takes no middleware.
const t = initTrpc<{ ctx: Context }>().concat(openapi());
const appRouter = {
admin: t.router(
{ meta: { route: { prefix: '/staff', tags: ['admin'] } } },
{ users, ban },
),
};
// RPC: admin.users. OpenAPI: GET /api/staff/users, tagged admin.The prefix goes before every route below the group: before an explicit .meta({ route: { path } }), and before the path derived from the keys below the group. Group tags come before a procedure's own. Groups nest.
A router's meta takes only the keys an extension gives a router-level form, like route; other meta is set on procedures.
A t.lazy(load, { meta: { route: { prefix } } }) subtree is scoped the same way (Lazy subtrees).
The handler loads unprefixed lazy subtrees at startup, and a prefixed one on the first request under its prefix. Pass a contract so every route is known up front and each subtree loads on its first request, whatever its prefix:
// build step
const contract = toContract(await loadRouter(appRouter));
// server
createOpenApiHandler({ router: appRouter, contract });