tRPC v11: End-to-End Type-Safe API Development
tRPC has become the go‑to solution for developers who crave a truly type‑safe bridge between their front‑end and back‑end. With the release of v11, the library introduces several ergonomic upgrades that make end‑to‑end type safety feel almost magical. In this article we’ll walk through the core concepts, set up a minimal project, and explore real‑world patterns that let you ship robust APIs without writing a single type definition twice.
Why tRPC v11 Matters
Before v11, tRPC already offered a compelling developer experience: you define your procedures once, and both the client and server infer the same types automatically. Version 11 tightens that contract, adds better error handling, and introduces router composition that scales effortlessly. The biggest win? No more manual schema duplication, which translates directly into fewer bugs and faster iteration cycles.
In practice, this means you can start a new feature, write a single TypeScript function, and instantly have IntelliSense on the client side. Even if you’re using a non‑TypeScript front‑end (e.g., Python or Go), the generated OpenAPI spec can be consumed with full type fidelity.
Getting Started: Project Scaffold
Let’s spin up a minimal tRPC v11 project using Next.js 14 and TypeScript 5. The steps are straightforward, but we’ll highlight the parts that differ from earlier versions.
- Initialize a new Next.js app:
npx create-next-app@latest my-trpc-app --typescript
cd my-trpc-app
- Install tRPC core packages:
npm install @trpc/server@11 @trpc/client@11 @trpc/react-query@11 zod
We also add zod for runtime validation, which pairs nicely with tRPC’s built‑in schema inference.
Configuring the Server
Create a src/server/trpc.ts file that sets up the context and the base router. The new initTRPC API in v11 encourages a functional style.
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
export const t = initTRPC.create({
// Enable automatic error formatting
errorFormatter({ shape }) {
return {
...shape,
data: {
...shape.data,
// Attach a timestamp for easier debugging
timestamp: new Date().toISOString(),
},
};
},
});
export const createContext = () => ({
// Example: inject a Prisma client, auth token, etc.
});
export type Context = ReturnType<typeof createContext>;
Now define a simple userRouter with CRUD‑style procedures. Notice how we use z.object to validate inputs, while tRPC infers the output types automatically.
import { t } from './trpc';
import { z } from 'zod';
export const userRouter = t.router({
// Query: fetch a user by ID
getUser: t.procedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ input }) => {
// Simulated DB call
return {
id: input.id,
name: 'Alice',
email: 'alice@example.com',
};
}),
// Mutation: create a new user
createUser: t.procedure
.input(z.object({
name: z.string().min(1),
email: z.string().email(),
}))
.mutation(async ({ input }) => {
// Simulated insertion
const newUser = {
id: crypto.randomUUID(),
...input,
};
// Return the created record
return newUser;
}),
});
Finally, combine routers in src/server/router.ts and expose the handler through Next.js API routes.
import { t } from './trpc';
import { userRouter } from './user';
export const appRouter = t.router({
user: userRouter,
});
export type AppRouter = typeof appRouter;
In pages/api/trpc/[trpc].ts we wire everything together:
import * as trpcNext from '@trpc/server/adapters/next';
import { appRouter, createContext } from '../../../src/server/router';
export default trpcNext.createNextApiHandler({
router: appRouter,
createContext,
});
Consuming the API on the Front‑End
With the server ready, the client side becomes a matter of creating a tRPC hook factory. The new createTRPCReact helper in v11 simplifies the setup.
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../src/server/router';
import { httpBatchLink } from '@trpc/client';
import superjson from 'superjson';
export const trpc = createTRPCReact<AppRouter>();
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
}),
],
transformer: superjson,
});
Wrap your application with the trpc.Provider in pages/_app.tsx:
import { trpc, trpcClient } from '../utils/trpc';
import type { AppProps } from 'next/app';
function MyApp({ Component, pageProps }: AppProps) {
return (
<trpc.Provider client={trpcClient} queryClient={new QueryClient()}>
<Component {...pageProps} />
</trpc.Provider>
);
}
export default MyApp;
Using Queries and Mutations
Now you can call the API with full type safety. Below is a component that lists a user and provides a form to create a new one.
import { trpc } from '../utils/trpc';
import { useState } from 'react';
export default function Users() {
const { data: user, isLoading } = trpc.user.getUser.useQuery(
{ id: 'd5f5c4b2-9c3a-4e2b-8a1e-2c7e8f9b1234' },
{ enabled: false } // We'll trigger manually
);
const createUser = trpc.user.createUser.useMutation();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const handleCreate = async () => {
await createUser.mutateAsync({ name, email });
// Reset form
setName('');
setEmail('');
};
return (
<div>
<h2>Create User</h2>
<input
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<button onClick={handleCreate} disabled={createUser.isLoading}>
{createUser.isLoading ? 'Saving…' : 'Save'}
</button>
<hr />
<h2>Fetch User</h2>
<button
onClick={() => trpc.user.getUser.refetch()}
disabled={isLoading}
>
{isLoading ? 'Loading…' : 'Load User'}
</button>
{user && (
<pre>{JSON.stringify(user, null, 2)}</pre>
)}
</div>
);
}
Notice how useQuery and useMutation infer the exact shape of input and output. If you try to pass an invalid email, TypeScript will flag it instantly.
Pro Tip: EnablenoUncheckedIndexedAccessin yourtsconfig.json. This forces you to handleundefinedcases that arise from optional fields, making your UI more resilient.
Advanced Patterns: Router Composition & Middleware
Large applications quickly outgrow a single router file. v11 introduces a cleaner composition API that mirrors the way you’d structure Redux slices or Express routers.
import { t } from './trpc';
import { userRouter } from './user';
import { postRouter } from './post';
import { authMiddleware } from './middlewares/auth';
// Apply auth middleware to the entire API
export const appRouter = t.router({
user: userRouter,
post: postRouter,
}).middleware(authMiddleware);
The authMiddleware runs before every procedure, giving you a single place to validate JWTs or session cookies.
import { initTRPC } from '@trpc/server';
import { getTokenFromHeaders } from '../utils/auth';
export const authMiddleware = initTRPC.create()
.middleware(async ({ ctx, next }) => {
const token = getTokenFromHeaders(ctx);
if (!token) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
// Attach user info to context for downstream procedures
ctx.user = await verifyToken(token);
return next({
ctx: {
...ctx,
// Ensure downstream procedures see the enriched context
user: ctx.user,
},
});
});
Procedural Inheritance with .merge()
Sometimes you need a subset of routes for public consumption and another for admin use. The .merge() method lets you create a “public” router that re‑exports only safe procedures.
export const publicRouter = t.router({
// Expose only read‑only endpoints
getUser: userRouter.getUser,
listPosts: postRouter.list,
});
export const adminRouter = t.router({
// Full CRUD for admins
user: userRouter,
post: postRouter,
}).merge(publicRouter); // Admin inherits public routes automatically
This pattern reduces duplication and makes permission audits straightforward.
Note: When using .merge(), the order matters—later merges can override earlier definitions, which can be handy for feature flags.
Real‑World Use Case: Multi‑Tenant SaaS Dashboard
Imagine a SaaS product where each tenant has its own set of projects, tasks, and users. Type safety across tenant boundaries is crucial to avoid leaking data. With tRPC v11 you can encode the tenant ID directly into the context and let TypeScript enforce it.
// src/server/context.ts
export const createContext = ({ req }: { req: NextApiRequest }) => {
const tenantId = req.headers['x-tenant-id'] as string | undefined;
if (!tenantId) {
throw new TRPCError({ code: 'BAD_REQUEST', message: 'Missing tenant ID' });
}
return { tenantId };
};
export type Context = ReturnType<typeof createContext>;
Now every procedure receives ctx.tenantId automatically, and you can type‑guard your data access layer accordingly.
export const projectRouter = t.router({
list: t.procedure.query(async ({ ctx }) => {
// Assume prisma is globally available
return prisma.project.findMany({
where: { tenantId: ctx.tenantId },
});
}),
create: t.procedure
.input(z.object({ name: z.string() }))
.mutation(async ({ ctx, input }) => {
return prisma.project.create({
data: {
name: input.name,
tenantId: ctx.tenantId,
},
});
}),
});
On the client side you simply pass the tenant header via the tRPC client configuration.
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
headers() {
return {
'x-tenant-id': getCurrentTenantId(), // e.g., from auth store
};
},
}),
],
transformer: superjson,
});
This approach eliminates the need for separate DTOs per tenant and guarantees at compile time that you never query the wrong tenant’s data.
Testing tRPC Endpoints
Testing is often overlooked, but with v11 you can spin up an in‑memory server that respects the same type contracts. The createTRPCClient helper works both in Node and the browser.
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { appRouter } from '../src/server/router';
import { inferProcedureInput, inferProcedureOutput } from '@trpc/server';
import { expect, test } from 'vitest';
// Create a test client pointing at the in‑process router
const client = createTRPCClient<typeof appRouter>({
links: [
httpBatchLink({
url: 'http://localhost:3000/api/trpc',
}),
],
});
test('createUser returns a valid UUID', async () => {
const input: inferProcedureInput<typeof appRouter.user.createUser> = {
name: 'Bob',
email: 'bob@example.com',
};
const result = await client.user.createUser.mutate(input);
expect(result.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
});
The inferProcedureInput and inferProcedureOutput utilities keep your test signatures in sync with the actual API, preventing stale test data.
Pro Tip: Usevitestwith the--watchflag while developing new procedures. The instant type feedback combined with live test runs creates a rapid feedback loop.
Performance Considerations
tRPC v11 ships with built‑in support for httpBatchLink, which batches multiple calls into a single HTTP request. This reduces round‑trip latency, especially on mobile networks. However, batching isn’t a silver bullet; you should still be mindful of payload size.
- Batch size limit: Keep each batch under ~64 KB to avoid hitting server limits.
- Cache wisely: Leverage React Query’s caching layer to prevent unnecessary refetches.
- Lazy loading: Load heavy routers (e.g., analytics) only when needed using dynamic imports.
For high‑traffic APIs you can also switch to a WebSocket link, which v11 supports out of the box. The API remains identical; you only change the transport layer.
Deploying to Production
Deploying a tRPC‑powered Next.js app is straightforward on Vercel, Netlify, or any Node server. The key is to ensure that the generated OpenAPI spec (if you need it) stays in sync with the server.
import { OpenApiGenerator } from '@trpc/openapi';
import { appRouter } from './router';
export const openapiSpec = OpenApiGenerator.generateDocument({
router: appRouter,
baseUrl: 'https://api.myapp.com',
});
Store openapiSpec as a static JSON file during the build step, then serve it via /openapi.json. External services (e.g., API gateways) can consume the spec without ever touching your TypeScript code.
Note: The OpenAPI generation is optional but highly recommended for cross‑language clients. It guarantees that the contract you expose externally never diverges from your internal types.