re
.input(z.object({
name: z.string().min(3),
ownerId: z.string().uuid(),
tags: z.array(z.string()).optional(),
}))
.mutation(async ({ input }) => {
const project = await db.project.create({
data: {
name: input.name,
owner_id: input.ownerId,
status: 'active',
metadata: { tags: input.tags ?? [] },
},
});
return project;
}),
getProjectMetrics: t.procedure
.input(z.object({ projectId: z.string().uuid() }))
.query(async ({ input }) => {
const metrics = await db.project.findUnique({
where: { id: input.projectId },
select: {
id: true,
task_count: true,
completion_rate: true,
last_updated: true,
},
});
if (!metrics) throw new Error('PROJECT_NOT_FOUND');
return metrics;
}),
});
export type AppRouter = typeof appRouter;
**Architecture Rationale:** Input validation uses Zod schemas directly within the procedure definition. This eliminates separate validation middleware layers and ensures the server rejects malformed payloads before execution. The `AppRouter` type export is critical: it enables client-side type inference without manual type declarations.
### Step 2: Configure the Client with React Query v5
tRPC v11 mandates React Query v5, which unlocks native Suspense support and improved mutation handling. The client setup abstracts HTTP transport while preserving React Query's caching and background refetching capabilities.
```typescript
// client/trpc-client.ts
import { createTRPCReact } from '@trpc/react-query';
import { httpBatchLink } from '@trpc/client';
import { httpSubscriptionLink } from '@trpc/client';
import type { AppRouter } from '../server/procedures';
export const trpc = createTRPCReact<AppRouter>();
export const trpcClient = trpc.createClient({
links: [
httpBatchLink({
url: '/api/trpc',
maxURLLength: 2000,
}),
httpSubscriptionLink({
url: '/api/trpc',
}),
],
});
Architecture Rationale: httpBatchLink groups multiple procedure calls into a single HTTP request, reducing network round-trips. httpSubscriptionLink enables real-time updates via Server-Sent Events (SSE). React Query v5's Suspense integration removes loading state boilerplate, allowing components to declare data dependencies declaratively.
Step 3: Implement Real-Time Updates via SSE
WebSockets require persistent connections, which conflict with serverless platform scaling models. tRPC v11's SSE implementation works seamlessly with stateless functions and edge runtimes.
// server/procedures.ts (continued)
import { observable } from '@trpc/server/observable';
export const appRouter = t.router({
// ... previous procedures
subscribeToProjectUpdates: t.procedure
.input(z.object({ projectId: z.string().uuid() }))
.subscription(async function* ({ input }) {
const stream = db.project.watch(input.projectId);
for await (const update of stream) {
yield {
type: update.event,
payload: update.data,
timestamp: new Date().toISOString(),
};
}
}),
});
Architecture Rationale: The subscription method returns an async generator. tRPC automatically serializes yielded values over SSE. Serverless platforms handle SSE connections gracefully because they terminate cleanly on function cold starts, unlike WebSocket handshakes which require sticky sessions or external brokers.
Step 4: Enable Lazy Router Loading
Large applications suffer from bundle bloat when all procedures are imported at startup. tRPC v11 supports dynamic router resolution.
// server/lazy-routers.ts
export const adminRouter = () => import('./routers/admin').then(m => m.adminRouter);
export const analyticsRouter = () => import('./routers/analytics').then(m => m.analyticsRouter);
export const appRouter = t.router({
core: coreRouter,
admin: adminRouter,
analytics: analyticsRouter,
});
Architecture Rationale: Dynamic imports defer router loading until the client explicitly calls a procedure within that namespace. This reduces initial JavaScript payload size and improves Time to Interactive (TTI) for dashboard applications.
Pitfall Guide
1. Treating Procedures Like REST Endpoints
Explanation: Developers often create one procedure per HTTP verb (getUsers, createUser, updateUser, deleteUser). This replicates REST patterns and defeats tRPC's domain-driven design advantage.
Fix: Model procedures around business operations (assignTask, archiveProject, recalculateMetrics). Group related operations under nested routers rather than flat endpoint lists.
2. Skipping Error Shape Standardization
Explanation: Unstructured errors leak implementation details and break client error handling. tRPC allows custom error formatting, but teams frequently ignore it.
Fix: Implement a global error formatter that maps Zod validation errors, database exceptions, and business logic failures to a consistent shape. Use t.procedure.use() middleware to catch and transform errors before they reach the client.
3. Misconfiguring SSE for Serverless Deployments
Explanation: SSE connections require the server to maintain an open stream. On platforms like Vercel or Cloudflare Workers, function timeouts or cold starts can abruptly terminate subscriptions.
Fix: Implement client-side reconnection logic with exponential backoff. Set explicit SSE heartbeat intervals (e.g., 30s) to keep connections alive. Avoid long-running database queries inside subscription generators; use event-driven architectures instead.
4. Bundling All Routers Eagerly
Explanation: Importing every router at the root level forces the client to download procedures for features the user may never access.
Fix: Use dynamic imports for feature-specific routers. Validate lazy loading behavior in production builds using bundle analyzers. Ensure the client router type correctly merges lazy-loaded namespaces.
5. Assuming tRPC Replaces Caching Strategies
Explanation: tRPC inherits React Query's caching, but developers sometimes treat it as a substitute for HTTP-level caching (ETags, CDN headers, stale-while-revalidate).
Fix: Use React Query's staleTime and gcTime for client-side cache control. For public-facing data, implement HTTP caching headers at the edge or reverse proxy layer. tRPC does not override network-level caching semantics.
6. Mixing Business Logic with Transport Logic
Explanation: Placing database queries, authentication checks, and business rules directly inside procedures creates tightly coupled code that is difficult to test.
Fix: Extract business logic into service layers or use cases. Procedures should only handle input validation, authorization context extraction, and service delegation. This preserves testability and enables procedure reuse across different transport layers if needed.
Explanation: Zod schemas validate structure but not semantic correctness. Accepting z.string() for IDs without format validation leads to database errors or security vulnerabilities.
Fix: Chain Zod refinements for semantic validation (.uuid(), .email(), .regex()). Implement server-side authorization checks that verify resource ownership before executing mutations. Never trust client-provided identifiers without validation.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| TypeScript monorepo (Next.js/SvelteKit) | tRPC v11 | Eliminates type boundary, accelerates iteration, native Suspense/SSE support | Low (reduced dev time, minimal infra) |
| Public API for third-party/mobile clients | REST + OpenAPI | Language-agnostic, standard caching, predictable versioning | Medium (spec maintenance, gateway costs) |
| Polyglot backend (Python/Go/Rust + TS frontend) | GraphQL or REST | tRPC requires TS end-to-end; GraphQL/REST bridge polyglot services | High (codegen pipelines, schema sync) |
| High-throughput public data feeds | REST with CDN caching | HTTP-level caching, edge distribution, conditional GETs | Low (CDN costs, minimal compute) |
| Internal dashboards with real-time updates | tRPC v11 + SSE | Fast dev loop, native subscriptions, serverless-friendly | Low (no WebSocket broker needed) |
Configuration Template
// server/trpc-setup.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { ZodError } from 'zod';
import { db } from './database';
import type { Session } from './auth';
const t = initTRPC.context<{ session: Session | null }>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Valid session required' });
}
return next({ ctx: { ...ctx, user: ctx.session.user } });
});
export const appRouter = t.router({
// Procedures go here
});
export type AppRouter = typeof appRouter;
// client/trpc-provider.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, httpSubscriptionLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '../server/trpc-setup';
import { useState } from 'react';
export const trpc = createTRPCReact<AppRouter>();
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: { staleTime: 1000 * 60 * 5, retry: 1 },
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({ url: '/api/trpc', maxURLLength: 2000 }),
httpSubscriptionLink({ url: '/api/trpc' }),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}
Quick Start Guide
- Initialize the server router: Install
@trpc/server, @trpc/client, @trpc/react-query, @tanstack/react-query, and zod. Create a root router file with initTRPC.create() and define your first procedure using .input() and .query() or .mutation().
- Expose the HTTP endpoint: Create an API route handler (e.g.,
/pages/api/trpc/[trpc].ts for Next.js Pages or /app/api/trpc/[trpc]/route.ts for App Router) that forwards requests to fetchRequestHandler from @trpc/server/adapters/fetch.
- Configure the client: Set up
createTRPCReact with httpBatchLink and httpSubscriptionLink. Wrap your application root with QueryClientProvider and trpc.Provider.
- Consume procedures in components: Replace manual
fetch or Axios calls with trpc.procedureName.useQuery() or useMutation(). Wrap data-dependent components in Suspense boundaries to leverage React Query v5's declarative loading states.
- Validate and deploy: Run type checks across the monorepo to confirm inference flows correctly. Deploy to a serverless or containerized environment, ensuring SSE endpoints are configured with appropriate timeout and heartbeat settings.