Skip to content

tRPC v12 isn’t on npm yet. The @trpc12/* packages on this site can’t be installed until it’s released. Use v11 today

tRPC

ExperimentalContracts and OpenAPI might change or go away in any release. Stability

REST routes

Serve the same router over REST: route meta, the handler, inputs, outputs and groups.

On this page

A procedure gets a REST route from its route meta, and experimental_createOpenApiHandler serves the routes next to the RPC handler (OpenAPI).

Route meta

The experimental_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 experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_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 experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_openapi());
const
const byId: Procedure<{
    type: "query";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: {
        id: string;
    };
    output: Post;
    errors: TrpcError<"NOT_FOUND", {
        id: string;
    }> | SchemaValidationError<z.ZodObject<{
        id: z.ZodString;
    }, z.core.$strip>>;
    services: never;
}>
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.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | 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;
    }> | SchemaValidationError<...>;
}>>
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.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | 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;
    }> | SchemaValidationError<z.ZodObject<{
        id: z.ZodString;
    }, z.core.$strip>>;
}>>) => Post | TrpcError<...>): Procedure<...>
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;
    }> | SchemaValidationError<z.ZodObject<{
        id: 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
.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;
    }> | SchemaValidationError<z.ZodObject<{
        id: z.ZodString;
    }, z.core.$strip>>;
}>>
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;
    }> | SchemaValidationError<z.ZodObject<{
        id: z.ZodString;
    }, z.core.$strip>>;
}>>
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;
    }> | SchemaValidationError<z.ZodObject<{
        id: z.ZodString;
    }, z.core.$strip>>;
}>>
opts
.
ResolverOpts<With<InputDef<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, ZodObject<{ id: ZodString; }, $strip>>, { ...; }>>.input: {
    id: string;
}
input
.id: stringid } }),
); const
const create: Procedure<{
    type: "mutation";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: {
        title: string;
    };
    output: Post;
    errors: SchemaValidationError<z.ZodObject<{
        title: z.ZodString;
    }, z.core.$strip>>;
    services: 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.$ZodIssueInvalidType<unknown> | z.core.$ZodIssueUnrecognizedKeys>> | 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): Procedure<{
    type: "mutation";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: {
        title: string;
    };
    output: Post;
    errors: SchemaValidationError<z.ZodObject<{
        title: z.ZodString;
    }, z.core.$strip>>;
    services: 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 
const authed: ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>>
authed
=
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>>>.use<{
    user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset, {}>(fn: MiddlewareFunction<{
    user: User | null;
}, undefined, {
    route?: Route | undefined;
}, {
    user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>> (+1 overload)
use
(
const requireUser: MiddlewareFunction<{
    user: User | null;
}, unknown, {
    route?: Route | undefined;
}, {
    user: User;
}, TrpcError<"UNAUTHORIZED", undefined>, never, Unset>
requireUser
)
.
ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.meta<{
    readonly route: {
        readonly spec: (op: OperationSpec) => {
            security: {
                bearer: never[];
            }[];
        };
    };
}>(meta: {
    readonly route: {
        readonly spec: (op: OperationSpec) => {
            security: {
                bearer: never[];
            }[];
        };
    };
} & UndeclaredMeta<{
    route?: Route | undefined;
}, {
    readonly route: {
        readonly spec: (op: OperationSpec) => {
            security: {
                bearer: never[];
            }[];
        };
    };
}>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        ...;
    };
    inputOut: Unset;
    errors: TrpcError<...>;
    shorts: never;
}>>
meta
({
route: {
    readonly spec: (op: OperationSpec) => {
        security: {
            bearer: never[];
        }[];
    };
}
route
: {
spec: (op: OperationSpec) => {
    security: {
        bearer: never[];
    }[];
}
spec
: (op: OperationSpecop) => ({ ...op: OperationSpecop,
security: {
    bearer: never[];
}[]
security
: [{ bearer: never[]bearer: [] }] }) } });
const
const me: Procedure<{
    type: "query";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: undefined;
    output: User;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    services: never;
}>
me
=
const authed: ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>>
authed
.
ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.meta<{
    readonly route: {
        readonly path: "/me";
        readonly spec: (op: OperationSpec) => {
            'x-internal': boolean;
        };
    };
}>(meta: {
    readonly route: {
        readonly path: "/me";
        readonly spec: (op: OperationSpec) => {
            'x-internal': boolean;
        };
    };
} & UndeclaredMeta<{
    route?: Route | undefined;
}, {
    readonly route: {
        readonly path: "/me";
        readonly spec: (op: OperationSpec) => {
            'x-internal': boolean;
        };
    };
}>): ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ...;
}>>
meta
({
route: {
    readonly path: "/me";
    readonly spec: (op: OperationSpec) => {
        'x-internal': boolean;
    };
}
route
: { path: "/me"path: '/me',
spec: (op: OperationSpec) => {
    'x-internal': boolean;
}
spec
: (op: OperationSpecop) => ({ ...op: OperationSpecop, 'x-internal': true }) },
}) .
ProcedureBuilder<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.query<User>(resolver: (opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>>) => User): Procedure<{
    type: "query";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: undefined;
    output: User;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    services: never;
}>
query
((
opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>>
opts
) =>
opts: ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, {
    ctx: Context;
    ctxAdded: {
        user: User;
    };
    inputOut: Unset;
    errors: TrpcError<"UNAUTHORIZED", undefined>;
    shorts: never;
}>>
opts
.
ResolverOpts<With<ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>, { ctx: Context; ctxAdded: { user: User; }; inputOut: Unset; errors: TrpcError<...>; shorts: never; }>>.ctx: {
    user: User;
}
ctx
.user: Useruser);

spec only changes the document (target: 'openapi'); experimental_toContract() leaves it out.

The handler

import { function cors(options?: CorsOptions): HttpMiddlewarecors } from '@trpc12/trpc/http';
import {
  function experimental_createOpenApiHandler<TRouter extends AnyRouter, TPlatform = undefined>(opts: OpenApiHandlerOptions<TRouter, TPlatform>): OpenApiHandler<TPlatform>experimental_createOpenApiHandler,
  function experimental_openApiReference(opts: OpenApiReferenceOptions): HttpMiddlewareexperimental_openApiReference,
} from '@trpc12/trpc/openapi';

const const openapi: OpenApiHandler<undefined>openapi = 
experimental_createOpenApiHandler<{
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}, undefined>(opts: OpenApiHandlerOptions<...>): OpenApiHandler<...>
experimental_createOpenApiHandler
({
BaseOpenApiHandlerOptions<{ post: { byId: Procedure<{ type: "query"; ctx: Context; meta: {}; input: { id: string; }; output: Post; errors: TrpcError<"NOT_FOUND", { id: string; }> | SchemaValidationError<ZodObject<{ id: ZodString; }, $strip>>; services: never; }>; list: Procedure<...>; create: Procedure<...>; }; }>.router: {
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}
router
:
const appRouter: {
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}
appRouter
,
createContext: (opts: CreateContextOpts<undefined>) => MaybePromise<{
    user: User | null;
}>
createContext
,
layer?: Layer.Layer<never, any, never> | undefinedlayer, BaseOpenApiHandlerOptions<TRouter extends AnyRouter>.middleware?: readonly HttpMiddleware[] | undefinedmiddleware: [ function cors(options?: CorsOptions): HttpMiddlewarecors(), function experimental_openApiReference(opts: OpenApiReferenceOptions): HttpMiddlewareexperimental_openApiReference({ router: objectrouter:
const appRouter: {
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}
appRouter
,
specGenerateOptions: GenerateOpenApiOptionsspecGenerateOptions: { GenerateOpenApiOptions.info: OpenApiInfoinfo: { OpenApiInfo.title: stringtitle: 'API', OpenApiInfo.version: stringversion: '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.

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

Cross-site forms

The handler refuses requests from another origin that use a method other than GET, HEAD or OPTIONS with a body that isn't application/json, answering 403 FORBIDDEN. A form or a no-cors fetch on another site can send a urlencoded, multipart or text/plain body (or none) without a CORS preflight, and the browser attaches the user's cookies. A JSON body needs a preflight, so cors() decides those requests.

  • Sec-Fetch-Site decides: same-origin and none pass, while same-site and cross-site don't, so a sibling subdomain counts as another origin. Browsers from before 2023 don't send that header; for them, the Origin header must name the request's host.
  • A request with neither header (from curl or a server) passes.
  • csrf: { trustedOrigins: ['https://www.example.com'] } lets that origin's forms through. csrf: false turns the guard off.

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.
  • Forms from another origin are refused unless it's trusted (Cross-site forms).

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((opts) => ({ headers: { etag: '…' }, body: savePost(opts.input) }));

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 experimental_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 
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 experimental_openapi(): ProcedureBuilder<RootDef<{}, OpenApiMeta>>experimental_openapi());
const
const appRouter: {
    admin: {
        readonly users: Procedure<{
            type: "query";
            ctx: Context;
            meta: {
                route?: Route | undefined;
            };
            input: undefined;
            output: {
                id: string;
                name: string;
            }[];
            errors: never;
            services: never;
        }>;
        readonly ban: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {
                route?: Route | undefined;
            };
            input: undefined;
            output: void;
            errors: never;
            services: never;
        }>;
    };
}
appRouter
= {
admin: {
    readonly users: Procedure<{
        type: "query";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: {
            id: string;
            name: string;
        }[];
        errors: never;
        services: never;
    }>;
    readonly ban: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: void;
        errors: never;
        services: never;
    }>;
}
admin
:
const t: TrpcRoot<Context, {
    route?: Route | undefined;
}, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>
t
.
TrpcRoot<Context, { route?: Route | undefined; }, ExtendedDef<RootDef<Context, {}>, RootDef<{}, OpenApiMeta>>>.router<{
    readonly users: Procedure<{
        type: "query";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: {
            id: string;
            name: string;
        }[];
        errors: never;
        services: never;
    }>;
    readonly ban: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: void;
        errors: never;
        services: never;
    }>;
}>(opts: RouterOptions<RouterMeta<{
    route?: Route | undefined;
}>>, routes: {
    readonly users: Procedure<{
        type: "query";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: {
            id: string;
            name: string;
        }[];
        errors: never;
        services: never;
    }>;
    readonly ban: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: void;
        errors: never;
        services: never;
    }>;
}): {
    readonly users: Procedure<{
        type: "query";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: {
            id: string;
            name: string;
        }[];
        errors: never;
        services: never;
    }>;
    readonly ban: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {
            route?: Route | undefined;
        };
        input: undefined;
        output: void;
        errors: never;
        services: never;
    }>;
}
router
(
{
RouterOptions<RouterMeta<{ route?: Route | undefined; }>>.meta?: RouterMeta<{
    route?: Route | undefined;
}> | undefined
meta
: { route?: RouteGroup | undefinedroute: { RouteGroup.prefix?: `/${string}` | undefinedprefix: '/staff', RouteGroup.tags?: readonly string[] | undefinedtags: ['admin'] } } },
{
users: Procedure<{
    type: "query";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: undefined;
    output: {
        id: string;
        name: string;
    }[];
    errors: never;
    services: never;
}>
users
,
ban: Procedure<{
    type: "mutation";
    ctx: Context;
    meta: {
        route?: Route | undefined;
    };
    input: undefined;
    output: void;
    errors: never;
    services: never;
}>
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 
const contract: ContractJson<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}>
contract
=
experimental_toContract<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}>(router: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}): ContractJson<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}>
experimental_toContract
(await
loadRouter<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}>(router: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}): Promise<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}>
loadRouter
(
const appRouter: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
appRouter
));
// server
experimental_createOpenApiHandler<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}, undefined>(opts: OpenApiHandlerOptions<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}, undefined>): OpenApiHandler<undefined>
experimental_createOpenApiHandler
({
BaseOpenApiHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.router: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
router
:
const appRouter: {
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}
appRouter
,
BaseOpenApiHandlerOptions<{ health: Procedure<{ type: "query"; ctx: {}; meta: {}; input: undefined; output: string; errors: never; services: never; }>; }>.contract?: ContractJson<{
    health: Procedure<{
        type: "query";
        ctx: {};
        meta: {};
        input: undefined;
        output: string;
        errors: never;
        services: never;
    }>;
}> | undefined
contract
});

Credits

The REST mapping follows oRPC's OpenAPI support: every procedure served with no configuration (routing), bracket notation and coercion from the schema.