Skip to content
tRPC

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.json

prefix 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; path and specPath move 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=true gives a number and a boolean.
  • JSON bodies are taken as they are.
  • multipart/form-data fields can hold Files.

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 Blob or File output is sent as the body. Promises and async iterables inside an output aren't supported over REST.
  • Set response.status = 204 in 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 no status; the response's status is it. Invalid input is a 400 with issues.
  • Unexpected errors are masked as 500 Internal server error unless exposeUnexpectedErrors is on. It defaults to on only in development and test.
  • Subscriptions are text/event-stream and resume from Last-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 });