tRPC: End-to-End Type Safety Without Codegen, and Where the Types Stop Protecting You
Key takeaways
tRPC lets a TypeScript client call server functions with full type checking and no code generation, because the client imports the server's router type. The types are only as honest as what actually crosses the network, though, and this guide covers both the setup and the places where compile-time types and runtime data can disagree.
Introduction
tRPC allows you to build fully type-safe APIs without schemas or code generation. It leverages TypeScript’s type inference to provide autocompletion and type safety from server to client.
Created by Alex Johansson (@alexdotjs), tRPC has rapidly become the standard for full-stack TypeScript applications. The core innovation is automatic type sharing — your server types flow directly to the client with zero configuration.
Why tRPC Matters
Traditional full-stack TypeScript pain:
1. Write server types
2. Write client types (manually duplicate)
3. Keep them in sync manually
4. Types drift — runtime errors
With tRPC:
1. Write server procedures
2. Import trpc client
3. Types automatically inferred
4. Impossible to call wrong API or use wrong types
Real-world adoption: the popular create-t3-app starter uses tRPC by default, and open-source products such as Cal.com use it for their internal API.
How it works. There is no schema file and no generated client. The server defines procedures in a router, and exports the router’s type: export type AppRouter = typeof appRouter. The client imports that type (only the type, never the server code) and creates a proxy object whose shape TypeScript derives from it. trpc.user.create.mutate(...) is checked against the procedure’s input and output types at compile time, and at runtime the proxy turns the call into an HTTP request to /api/trpc/user.create. Rename a procedure or change its input on the server, and every client call that no longer matches becomes a compile error in the same editor session.
That mechanism explains both the strength and the main limitation. The client must be able to import the server’s TypeScript types, which in practice means the same repository or a monorepo with a shared package. That is why tRPC fits a Next.js app or a monorepo so well, and why it is the wrong tool for an API consumed by other teams, mobile apps written in Swift or Kotlin, or third parties.
When to use tRPC:
- Full TypeScript stack (Next.js, Remix, Solid Start)
- Internal APIs (not public REST APIs consumed by non-TS clients)
- Rapid iteration — change server types, client updates instantly
- Small-to-medium teams where everyone uses TypeScript
When NOT to use tRPC:
- Public API for mobile apps, third-party devs (use REST + OpenAPI)
- Polyglot backends (Java, Python, Go) — tRPC is TypeScript-only
- GraphQL’s flexibility is required (complex data fetching patterns)
Traditional API
// Server
app.post('/api/user', (req, res) => {
const { name, email } = req.body;
// No type safety!
const user = db.createUser({ name, email });
res.json(user);
});
// Client
const res = await fetch('/api/user', {
method: 'POST',
body: JSON.stringify({ name: 'Alice', email: '[email protected]' }),
});
const user = await res.json(); // any type!
With tRPC
// Server
const appRouter = router({
user: {
create: publicProcedure
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(({ input }) => {
return db.createUser(input); // Fully typed!
}),
},
});
// Client
const user = await trpc.user.create.mutate({
name: 'Alice',
email: '[email protected]',
}); // Fully typed!
Installation
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
A note on versions: this guide uses the classic @trpc/react-query integration, which works in tRPC v10 and v11. tRPC v11 also introduced a newer TanStack Query integration (@trpc/tanstack-react-query) built around queryOptions, and moved subscriptions toward async generators and server-sent events. The concepts below apply to both, but check the documentation for your version before copying setup code.
Server Setup
// server/trpc.ts
import { initTRPC } from '@trpc/server';
import superjson from 'superjson';
const t = initTRPC.create({
transformer: superjson, // keep Date, Map, Set, BigInt intact over the wire
});
export const router = t.router;
export const publicProcedure = t.procedure;
The transformer line fixes the most common tRPC surprise. Responses travel as JSON, and JSON has no Date type. Without a transformer, a procedure that returns { createdAt: new Date() } delivers createdAt as an ISO string, but the inferred client type still says Date. Calling post.createdAt.getTime() compiles and then throws at runtime. With superjson on both server and client (see the client setup below), Date, Map, Set, and BigInt survive the round trip and the types are honest again.
This is the tRPC bug I would warn about first, because it undermines the one thing people adopt tRPC for. The compiler says everything is typed, so nobody checks, and the first sign is a getTime is not a function error in production, usually from a date field that was added to a Prisma model months after the tRPC setup was written.
// server/routers/user.ts
import { z } from 'zod';
import { router, publicProcedure } from '../trpc';
export const userRouter = router({
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
return await db.user.findUnique({ where: { id: input } });
}),
list: publicProcedure.query(async () => {
return await db.user.findMany();
}),
create: publicProcedure
.input(z.object({
name: z.string(),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
return await db.user.create({ data: input });
}),
});
// server/routers/_app.ts
import { router } from '../trpc';
import { userRouter } from './user';
export const appRouter = router({
user: userRouter,
});
export type AppRouter = typeof appRouter;
Only the type AppRouter should ever reach the client, through import type. A regular import { appRouter } in a client component would pull the server code, including database clients and secrets from environment variables, into the browser bundle, or break the build. Using import type everywhere on the client side makes that mistake impossible.
Also notice that .input(z.number()) is not just a type annotation. Zod validates the incoming data at runtime before the procedure runs, and rejects invalid input with a BAD_REQUEST error. This matters because the compile-time types only protect callers that go through your typed client. Anyone can send an HTTP request to /api/trpc/user.create with arbitrary JSON, so the input schema is the actual security boundary. Procedures without .input() receive whatever is sent.
Server Integration (Next.js)
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext, // from server/trpc.ts (see Context and Authentication)
});
export { handler as GET, handler as POST };
Queries are sent as GET requests (so they can be cached by the browser or a CDN), mutations as POST, which is why the route exports both. The createContext function runs for every request and builds the ctx object that procedures receive. The Context and Authentication section below shows one that reads the auth header.
Client Setup
// utils/trpc.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '@/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
// app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { trpc } from '@/utils/trpc';
import { useState } from 'react';
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc', // relative: works in any environment in the browser
transformer: superjson, // must match the server (v11: set on the link)
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Many tutorials hard-code http://localhost:3000 here, which works in development and silently breaks in every deployed environment. In the browser, a relative URL is enough. On the server (for example, when prefetching during SSR), a relative URL does not work because there is no page origin, so production setups compute an absolute URL from an environment variable there. And the transformer must be configured identically on both sides: a server using superjson with a client that does not produce unreadable responses. (In tRPC v10 the transformer is set on createClient instead of the link.)
// app/layout.tsx
import { TRPCProvider } from './providers';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}
Client Usage
'use client';
import { trpc } from '@/utils/trpc';
export function UserList() {
const { data: users, isLoading } = trpc.user.list.useQuery();
if (isLoading) return <div>Loading...</div>;
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export function UserProfile({ userId }: { userId: number }) {
const { data: user } = trpc.user.getById.useQuery(userId);
return <div>{user?.name}</div>;
}
export function CreateUserForm() {
const createUser = trpc.user.create.useMutation();
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
await createUser.mutateAsync({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create</button>
</form>
);
}
The React hooks are thin wrappers around TanStack Query, so everything TanStack Query does applies: caching, background refetching, deduplication of identical queries, and retries. The one thing it does not do automatically is update related data after a mutation. After createUser succeeds, user.list still shows the old list until it refetches. The usual fix is to invalidate it in onSuccess: get const utils = trpc.useUtils() and call utils.user.list.invalidate(). Forgetting this is the most common “my list does not update” report with tRPC, and it is not a tRPC bug.
Context and Authentication
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
export const createContext = async (opts: FetchCreateContextFnOptions) => {
const token = opts.req.headers.get('authorization');
const user = await getUserFromToken(token);
return { user };
};
type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
// Public procedure (no auth)
export const publicProcedure = t.procedure;
// Protected procedure (requires auth)
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
user: ctx.user, // Now guaranteed to be defined
},
});
});
// Usage
export const postRouter = router({
// Anyone can read
list: publicProcedure.query(async () => {
return await db.post.findMany();
}),
// Must be logged in to create
create: protectedProcedure
.input(z.object({ title: z.string(), content: z.string() }))
.mutation(async ({ ctx, input }) => {
return await db.post.create({
data: {
...input,
authorId: ctx.user.id, // ctx.user is guaranteed
},
});
}),
});
The middleware pattern is where tRPC’s type inference shines. Before the middleware, ctx.user has type User | null. The middleware calls next({ ctx: { user: ctx.user } }) after checking it, and tRPC infers that in every procedure built from protectedProcedure, ctx.user is User. A procedure cannot forget the null check, because the type system has already done it. Extend the same idea for roles: an adminProcedure built on protectedProcedure that checks ctx.user.role === 'admin'.
Keep in mind what this does not cover: authorization on specific records. protectedProcedure knows the user is logged in, not that they own the post they are editing. That check has to happen inside each procedure, as the blog example below shows.
Error Handling
import { TRPCError } from '@trpc/server';
export const userRouter = router({
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
const user = await db.user.findUnique({ where: { id: input } });
if (!user) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'User not found',
});
}
return user;
}),
});
// Client error handling
function UserProfile({ userId }: { userId: number }) {
const { data: user, error } = trpc.user.getById.useQuery(userId);
if (error) {
return <div>Error: {error.message}</div>;
}
return <div>{user?.name}</div>;
}
TRPCError codes map to HTTP status codes (NOT_FOUND → 404, UNAUTHORIZED → 401, FORBIDDEN → 403, BAD_REQUEST → 400), so logs and monitoring still see meaningful statuses. Any other exception thrown in a procedure becomes an INTERNAL_SERVER_ERROR. By default its message is sent to the client, which can leak details such as SQL errors. In production, configure an errorFormatter in initTRPC.create() to strip internal messages and stack traces, and log the full error on the server instead.
Batching
// Vanilla (non-React) client: e.g. scripts, tests, server-to-server
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
import type { AppRouter } from '@/server/routers/_app';
const client = createTRPCClient<AppRouter>({
links: [httpBatchLink({ url: 'https://example.com/api/trpc', transformer: superjson })],
});
const [user1, user2, user3] = await Promise.all([
client.user.getById.query(1),
client.user.getById.query(2),
client.user.getById.query(3),
]);
// Single HTTP request with all 3 queries!
The React trpc object from the client setup only has hooks (useQuery, useMutation). For imperative calls outside components, use a vanilla client as above (createTRPCProxyClient in v10). httpBatchLink collects calls made in the same tick into one HTTP request, which helps pages with many small queries. The trade-off is that the whole batch waits for the slowest procedure, since the response is sent once all results are ready. When one query is much slower than the rest, httpBatchStreamLink returns results as they complete, or you can route slow procedures through a non-batching httpLink with splitLink.
Subscriptions (WebSockets)
// Server
import { observable } from '@trpc/server/observable';
export const messageRouter = router({
onMessage: publicProcedure.subscription(() => {
return observable<{ id: number; text: string }>((emit) => {
const onMessage = (msg: Message) => {
emit.next(msg);
};
eventEmitter.on('message', onMessage);
return () => {
eventEmitter.off('message', onMessage);
};
});
}),
});
// Client
function MessageList() {
trpc.message.onMessage.useSubscription(undefined, {
onData(message) {
console.log('New message:', message);
},
});
return <div>Messages</div>;
}
Subscriptions need a long-lived connection, which the Next.js App Router route handler above does not provide in the WebSocket form. You need a separate WebSocket server (applyWSSHandler) and a wsLink on the client, or, in tRPC v11, server-sent events over normal HTTP with httpSubscriptionLink, which works in serverless environments. The in-memory eventEmitter only reaches clients connected to the same server process. With several instances, events must go through a shared channel such as Redis pub/sub.
Real-World Example: Blog API
// server/routers/blog.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
export const blogRouter = router({
// List posts with pagination
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(10),
cursor: z.number().optional(),
})
)
.query(async ({ input }) => {
const posts = await db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
});
let nextCursor: number | undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return {
posts,
nextCursor,
};
}),
// Get single post
getById: publicProcedure
.input(z.number())
.query(async ({ input }) => {
const post = await db.post.findUnique({
where: { id: input },
include: { author: true, comments: true },
});
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND' });
}
return post;
}),
// Create post (auth required)
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
tags: z.array(z.string()).optional(),
})
)
.mutation(async ({ ctx, input }) => {
return await db.post.create({
data: {
...input,
authorId: ctx.user.id,
},
});
}),
// Update post (auth + ownership check)
update: protectedProcedure
.input(
z.object({
id: z.number(),
title: z.string().optional(),
content: z.string().optional(),
})
)
.mutation(async ({ ctx, input }) => {
const post = await db.post.findUnique({ where: { id: input.id } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return await db.post.update({
where: { id: input.id },
data: input,
});
}),
// Delete post
delete: protectedProcedure
.input(z.number())
.mutation(async ({ ctx, input }) => {
const post = await db.post.findUnique({ where: { id: input } });
if (post?.authorId !== ctx.user.id) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
await db.post.delete({ where: { id: input } });
return { success: true };
}),
});
The ownership checks in update and delete are correct in spirit but have a small race: the post is read, checked, and then written in separate queries. A cleaner pattern is to put the ownership condition into the write itself, so the check and the change are atomic: db.post.updateMany({ where: { id: input.id, authorId: ctx.user.id }, data }), then treat a count of 0 as not found or forbidden. Also note that update passes the whole input, including id, as data. Destructure it first (const { id, ...data } = input) so the primary key is never part of an update payload.
The cursor pagination pattern (fetch limit + 1, pop the extra item to decide whether there is a next page) pairs with useInfiniteQuery on the client, which passes nextCursor back automatically. Note that Prisma’s cursor option includes the cursor row itself unless you add skip: 1. The code above avoids a duplicate only because nextCursor is set to the id of the popped extra item, which was not returned to the client.
Dependencies in context make procedures testable
export const createContext = async () => {
return {
db: prisma,
redis: redisClient,
logger: winston,
};
};
Procedures that import prisma or a Redis client at module level can only be tested against the real services. When those clients arrive through ctx, a test can call a procedure directly with a context it builds itself, without an HTTP server. Recent tRPC versions provide a server-side caller for this, t.createCallerFactory(appRouter), which returns a function that takes a context and gives you caller.user.getById(1) with the same input validation and middleware as a real request. The same caller is how Server Components call procedures without going through HTTP. Keep createContext cheap, since it runs once per HTTP request (all calls in a batch share one context): create clients once at module level and put references to them in the context, rather than connecting inside it.
When tRPC’s shared types stop protecting you
Because the client and server share types at build time, tRPC assumes they are deployed together. In a Next.js app that is true. For a mobile app or a separately deployed frontend, it is not: an old client version can keep calling a procedure whose input has changed, and the types that “guaranteed” compatibility only described the code at build time. If clients can lag behind the server, treat procedure inputs like a public API: only add optional fields, keep old procedures until old clients are gone, and consider whether REST or GraphQL with explicit versioning fits better.