
Trpc
- 7 installs
- 5 repo stars
- Updated June 3, 2026
- dimitrigilbert/ai-skills
Set up type-safe tRPC routers and clients across any framework using Zod-validated procedures.
About
Generic tRPC implementation guide for building type-safe APIs across any framework and package manager. A developer uses it to set up tRPC routers and clients in a TypeScript app.
- Framework-agnostic tRPC router with Zod-validated query and mutation procedures
- Type-safe frontend client via httpBatchLink and TanStack React Query
Trpc by the numbers
- 7 all-time installs (skills.sh)
- Ranked #3,637 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dimitrigilbert/ai-skills --skill trpcAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 7 |
|---|---|
| repo stars | ★ 5 |
| Last updated | June 3, 2026 |
| Repository | dimitrigilbert/ai-skills ↗ |
What it does
Set up type-safe tRPC routers and clients across any framework using Zod-validated procedures.
Files
tRPC Quick Start
Backend Router
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
export const t = initTRPC.create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const myRouter = router({
getItem: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return await getItem(input.id);
}),
updateItem: publicProcedure
.input(z.object({ id: z.string(), data: z.any() }))
.mutation(async ({ input }) => {
return await updateItem(input.id, input.data);
}),
});
export const appRouter = router({
healthCheck: publicProcedure.query(() => "OK"),
my: myRouter,
});
export type AppRouter = typeof appRouter;Frontend Client
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createTRPCOptionsProxy } from "@trpc/tanstack-react-query";
import type { AppRouter } from "./path/to/router";
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient();
export const trpcClient = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "/api/trpc" })],
});
export const trpc = createTRPCOptionsProxy<AppRouter>({
client: trpcClient,
queryClient,
});Usage
const { data } = useQuery(trpc.my.getItem.queryOptions({ id }));
const mutation = useMutation(trpc.my.updateItem.mutationOptions());Error Handling
throw new TRPCError({
code: "NOT_FOUND",
message: "Resource not found",
});Advanced
- Subscriptions: See SUBSCRIPTIONS.md
- Server Adapters: See ADAPTERS.md
- Context: See CONTEXT.md
- Router Merging: See ROUTERS.md
- Middleware: See MIDDLEWARE.md
- Frontend Patterns: See FRONTEND.md
- Client Links: See LINKS.md
- Schemas: See SCHEMAS.md
- Client Errors: See CLIENT_ERRORS.md
- Install: See INSTALL.md
Server Adapters
Next.js
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "./router";
import { createContext } from "./context";
export default (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
});Express
import * as trpcExpress from "@trpc/server/adapters/express";
import { appRouter } from "./router";
import { createContext } from "./context";
app.use(
"/trpc",
trpcExpress.createExpressMiddleware({
router: appRouter,
createContext,
}),
);Fastify
import fastifyTRPCPlugin from "@trpc/server/adapters/fastify";
import { appRouter } from "./router";
import { createContext } from "./context";
app.register(fastifyTRPCPlugin, {
prefix: "/trpc",
trpcOptions: { router: appRouter, createContext },
});Hono
import { trpcServer } from "@hono/trpc-server";
import { appRouter } from "./router";
import { createContext } from "./context";
app.use("/api/trpc/*", trpcServer({
router: appRouter,
createContext: (_opts, context) => createContext({ context }),
}));Bun
import { fetchRequestHandler } from "@trpc/server/adapters/bun";
import { appRouter } from "./router";
import { createContext } from "./context";
Bun.serve({
async fetch(req) {
return fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
});
},
});Client Error Handling
TRPCError Type
import type { TRPCError } from "@trpc/client";
const { error } = useQuery(trpc.items.get.queryOptions({ id }));
if (error?.data?.code === "NOT_FOUND") {
return <div>Item not found</div>;
}
if (error?.data?.code === "UNAUTHORIZED") {
return <div>Please log in</div>;
}Error Codes
| Code | Meaning |
|---|---|
| BAD_REQUEST | Invalid input |
| UNAUTHORIZED | Not logged in |
| FORBIDDEN | No permission |
| NOT_FOUND | Resource missing |
| INTERNAL_SERVER_ERROR | Server error |
| TOO_MANY_REQUESTS | Rate limited |
Global Error Handling (TanStack Query)
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
console.error("Query error:", error);
if (error?.data?.code === "UNAUTHORIZED") {
// Redirect to login
window.location.href = "/login";
}
},
}),
});Mutation Error Handling
const mutation = useMutation(
trpc.items.create.mutationOptions({
onError: (error) => {
if (error?.data?.code === "BAD_REQUEST") {
toast.error("Invalid input");
} else if (error?.data?.code === "UNAUTHORIZED") {
toast.error("Not logged in");
} else {
toast.error("Something went wrong");
}
},
})
);Context
Pattern
Context provides request-specific data (user, session, db connection):
export type CreateContextOptions = {
req?: Request;
res?: Response;
};
export async function createContext({ req }: CreateContextOptions) {
const user = await getUserFromRequest(req);
return {
user,
session: user?.session ?? null,
};
}
export type Context = Awaited<ReturnType<typeof createContext>>;Initialization with Context
import { initTRPC, TRPCError } from "@trpc/server";
import type { Context } from "./context";
export const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
// Protected procedure using context
export const protectedProcedure = publicProcedure.use(async (opts) => {
if (!opts.ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return opts.next();
});Usage in Procedures
getUser: protectedProcedure.query(async ({ ctx }) => {
return ctx.user; // Context provides user
});Frontend Patterns
Query with States
const { data, isLoading, error } = useQuery(
trpc.items.list.queryOptions({ limit: 20 })
);
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <Display data={data} />;Mutation
const mutation = useMutation(trpc.items.create.mutationOptions());
mutation.mutate({ name: "Item" });Invalidation
const queryClient = useQueryClient();
const mutation = useMutation(
trpc.items.update.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: trpc.items.list.queryKey(),
});
},
})
);Infinite Scroll
const { data, fetchNextPage, hasNextPage } = useInfiniteQuery(
trpc.items.list.queryOptions(),
{
getNextPageParam: (last) => last.nextCursor,
}
);Conditional Query
const { data } = useQuery(
trpc.user.get.queryOptions({ id }),
{ enabled: !!id }
);Install Packages
Core Backend
pnpm add @trpc/server zod superjson
npm install @trpc/server zod superjson
yarn add @trpc/server zod superjson
bun add @trpc/server zod superjsonServer Adapter (Choose One)
# Next.js (built-in, no extra package)
pnpm add @trpc/server
# Express
pnpm add @trpc/server/adapters/express express
# Fastify
pnpm add @trpc/server/adapters/fastify fastify
# Hono
pnpm add @hono/trpc-server hono
# Bun
pnpm add @trpc/server/adapters/bunFrontend (React)
pnpm add @trpc/client @trpc/tanstack-react-query @tanstack/react-query
npm install @trpc/client @trpc/tanstack-react-query @tanstack/react-query
yarn add @trpc/client @trpc/tanstack-react-query @tanstack/react-query
bun add @trpc/client @trpc/tanstack-react-query @tanstack/react-queryNotes
- Use your package manager of choice
- Versions are managed by your package.json
- Check tRPC docs for latest
Client Links
httpBatchLink (Batching Requests)
import { createTRPCClient, httpBatchLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: "/api/trpc",
maxURLLength: 2083, // Optional
}),
],
});Batches multiple requests into single HTTP call. Improves performance.
httpLink (Single Requests)
import { createTRPCClient, httpLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
httpLink({
url: "/api/trpc",
}),
],
});Each request is separate HTTP call.
Split Links (Route by Operation)
import { splitLink, httpLink, httpBatchLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({
links: [
splitLink({
condition: (op) => op.type === "subscription",
trueLink: wsLink(), // Subscriptions use WebSocket
falseLink: httpBatchLink(), // Queries/mutations use HTTP
}),
],
});Headers
httpBatchLink({
url: "/api/trpc",
headers: () => ({
authorization: `Bearer ${getToken()}`,
"x-custom-header": "value",
}),
});Transformer (superjson)
import superjson from "superjson";
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: "/api/trpc" })],
transformer: superjson,
});Enables Date, Map, Set, and other complex types serialization.
Middleware
Protected Procedure
export const protectedProcedure = publicProcedure.use(async (opts) => {
const user = await getCurrentUser(opts.ctx);
if (!user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return opts.next({ ctx: { ...opts.ctx, user } });
});Rate Limiting
export const rateLimited = publicProcedure.use(async (opts) => {
const allowed = await checkRateLimit(opts.ctx);
if (!allowed) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Rate limit exceeded" });
}
return opts.next();
});Logging
export const withLogging = publicProcedure.use(async (opts) => {
console.log(`[${opts.type}] ${opts.path}`, opts.input);
const result = await opts.next();
console.log(`[${opts.type}] ${opts.path}: ${Date.now() - start}ms`);
return result;
});Usage
export const adminRouter = router({
deleteUser: protectedProcedure
.use(isAdmin)
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => await deleteUser(input.id)),
});Router Merging
Multiple Routers
// features/users/router.ts
export const usersRouter = router({
list: publicProcedure.query(async () => db.users.findMany()),
});
// features/posts/router.ts
export const postsRouter = router({
list: publicProcedure.query(async () => db.posts.findMany()),
});
// routers/index.ts
export const appRouter = router({
healthCheck: publicProcedure.query(() => "OK"),
users: usersRouter,
posts: postsRouter,
});
export type AppRouter = typeof appRouter;Type Export (Monorepo)
// package.json
{
"exports": {
".": { "types": "./src/index.ts", "default": "./src/index.ts" },
"./router": { "types": "./src/routers/index.ts", "default": "./src/routers/index.ts" }
}
}Frontend Import
import type { AppRouter } from "@my-app/api";
import { createTRPCClient, httpBatchLink } from "@trpc/client";
const client = createTRPCClient<AppRouter>({ /* ... */ });Complex Zod Schemas
Nested Objects
publicProcedure
.input(z.object({
user: z.object({
id: z.string().uuid(),
name: z.string().min(1).max(100),
email: z.string().email(),
}),
preferences: z.object({
theme: z.enum(["light", "dark"]),
notifications: z.boolean(),
}),
}))
.query(async ({ input }) => { /* ... */ });Arrays with Validation
publicProcedure
.input(z.object({
items: z.array(z.object({
id: z.string(),
quantity: z.number().int().positive(),
})),
}))
.mutation(async ({ input }) => { /* ... */ });Optional Fields with Defaults
publicProcedure
.input(z.object({
search: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
page: z.number().int().positive().default(1),
}))
.query(async ({ input }) => { /* ... */ });Enums and Unions
publicProcedure
.input(z.object({
type: z.enum(["user", "admin", "guest"]),
status: z.union([z.literal("active"), z.literal("inactive")]),
}))
.query(async ({ input }) => { /* ... */ });Custom Validation
publicProcedure
.input(z.object({
password: z.string().min(8).refine(
(val) => /[A-Z]/.test(val),
{ message: "Must contain uppercase letter" }
),
}))
.mutation(async ({ input }) => { /* ... */ });Subscriptions
Pattern
Async generator with AbortSignal:
onEvent: publicProcedure
.input(z.object({ id: z.string().optional() }))
.subscription(async function* (opts) {
const controller = new AbortController();
const signal = controller.signal;
if (opts.signal) {
opts.signal.addEventListener('abort', () => controller.abort());
}
const queue: Event[] = [];
const unsubscribe = subscribeToEvents((event) => queue.push(event));
try {
while (!signal.aborted) {
if (queue.length > 0) {
const event = queue.shift();
if (event) yield event;
}
await new Promise(resolve => setTimeout(resolve, 10));
}
} finally {
unsubscribe();
}
}),Key Points
opts.signalis client abort signal- Create local AbortController for cleanup
- Always call unsubscribe in finally block
- Use queue to buffer events