Implementing TRPC 101
tRPC (TypeScript Remote Procedure Call) eliminates the gap between your frontend and backend by letting you call server functions directly from the client — with full TypeScript inference, zero code generation, and no API schemas to maintain.
What is tRPC?
tRPC is a framework for building end-to-end type-safe APIs in TypeScript. Instead of defining REST routes or GraphQL schemas, you write plain TypeScript functions on the server and call them from the client as if they were local. Change a return type on the backend, and your frontend immediately shows a type error — no build step, no codegen.
Why tRPC Over REST or GraphQL?
| REST | GraphQL | tRPC | |
| Type Safety | Manual | With codegen | Built-in |
| Boilerplate | High | High | Minimal |
| Schema Definition | OpenAPI / Swagger | SDL + Resolvers | None needed |
| Learning Curve | Low | Steep | Low |
| Best For | Public APIs | Complex graph data | Fullstack TS monorepos |
tRPC works best when both your frontend and backend are TypeScript. If you need a public API consumed by non-TS clients, REST or GraphQL is a better fit.
The Bridge Between Frontend & Backend
tRPC acts as a type-safe API layer that sits between your UI and your server logic. Here's how the pieces connect:
┌─────────────────────────────────────────────────┐
│ Frontend (React / Next.js) │
│ │
│ trpc.dashboard.getStats.useQuery() │
│ ↕ full type inference, no fetch() │
├─────────────────────────────────────────────────┤
│ tRPC Client → httpBatchLink → /api/trpc │
├─────────────────────────────────────────────────┤
│ tRPC Router (API Layer) │
│ │
│ ├── dashboardRouter │
│ │ ├── getStats (query) │
│ │ └── updateConfig (mutation) │
│ ├── userRouter │
│ │ ├── me (query) │
│ │ └── updateProfile(mutation) │
│ └── notificationRouter │
│ └── onNew (subscription) │
├─────────────────────────────────────────────────┤
│ Handlers / Business Logic │
│ (DB queries, external APIs, transforms) │
└─────────────────────────────────────────────────┘
The key insight: the AppRouter type is exported from the server and imported by the client. Only the type crosses the boundary — no runtime code leaks from server to client.
Core Concepts
Routers & Procedures
A router groups related procedures. A procedure is a single endpoint — either a query (read), mutation (write), or subscription (real-time stream).
Context
The context object is created per-request and passed to every procedure. This is where you attach database connections, auth sessions, and request metadata.
Middleware
Middleware wraps procedures to handle cross-cutting concerns like authentication, logging, and rate limiting. Middleware can also swap the context, so downstream procedures get enriched data (e.g., a verified user object).
Input Validation
tRPC uses Zod (or any validator with a .parse() method) to validate inputs at runtime. The validated types automatically flow to the client.
Setup Guide (Next.js App Router + tRPC v11)
1. Install Dependencies
npm install @trpc/server @trpc/client @trpc/tanstack-react-query \
@tanstack/react-query@latest zod client-only server-only superjson
superjson is optional but recommended — it lets you send Date, Map, Set, and other non-JSON types between client and server seamlessly.
2. Initialize tRPC (Server)
Create /trpc/init.ts:
import { initTRPC, TRPCError } from '@trpc/server';
import { cache } from 'react';
import superjson from 'superjson';
// Context is created once per request
export const createTRPCContext = cache(async () => {
// Add auth session, DB connection, etc.
return {
userId: null as string | null,
};
});
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;
3. Create Your First Router
Create /trpc/routers/dashboard.ts:
import { z } from 'zod';
import { router, publicProcedure } from '../init';
export const dashboardRouter = router({
// Query — fetches data
getStats: publicProcedure.query(async () => {
// Your DB call or business logic here
return {
totalUsers: 1250,
activeToday: 342,
revenue: 48000,
};
}),
// Mutation — modifies data
updateConfig: publicProcedure
.input(
z.object({
theme: z.enum(['light', 'dark']),
timezone: z.string(),
})
)
.mutation(async ({ input }) => {
// Save config to DB
return { success: true, applied: input };
}),
});
4. Merge Into App Router
Create /trpc/router.ts:
import { router } from './init';
import { dashboardRouter } from './routers/dashboard';
import { userRouter } from './routers/user';
export const appRouter = router({
dashboard: dashboardRouter,
user: userRouter,
});
// Only export the TYPE — not the router itself
export type AppRouter = typeof appRouter;
5. Create the API Handler
Create /app/api/trpc/[trpc]/route.ts:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { createTRPCContext } from '@/trpc/init';
import { appRouter } from '@/trpc/router';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };
6. Set Up the Client Provider
Create /trpc/client.tsx:
'use client';
import { createTRPCClient, httpBatchLink } from '@trpc/client';
import { createTRPCContext } from '@trpc/tanstack-react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import superjson from 'superjson';
import type { AppRouter } from './router';
// Create typed hooks
const { TRPCProvider, useTRPC } = createTRPCContext\<AppRouter>();
function getBaseUrl() {
if (typeof window !== 'undefined') return '';
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
export function TRPCClientProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
createTRPCClient\<AppRouter>({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
transformer: superjson,
}),
],
})
);
return (
<QueryClientProvider client={queryClient}>
<TRPCProvider trpcClient={trpcClient} queryClient={queryClient}>
{children}
</TRPCProvider>
</QueryClientProvider>
);
}
export { useTRPC };
Wrap your root layout with \<TRPCClientProvider>.
7. Call Procedures From the Frontend
'use client';
import { useTRPC } from '@/trpc/client';
import { useQuery, useMutation } from '@tanstack/react-query';
export function DashboardStats() {
const trpc = useTRPC();
// Type-safe query — return type is inferred automatically
const { data, isPending } = useQuery(trpc.dashboard.getStats.queryOptions());
// Type-safe mutation
const updateConfig = useMutation(
trpc.dashboard.updateConfig.mutationOptions()
);
if (isPending) return <div>Loading...</div>;
return (
<div>
<h2>Total Users: {data?.totalUsers}</h2>
<button onClick={() => updateConfig.mutate({ theme: 'dark', timezone: 'Asia/Tokyo' })}>
Switch to Dark
</button>
</div>
);
}
Middleware & Authentication
Middleware is how you protect routes and enrich context. Here's a common auth pattern:
import { TRPCError } from '@trpc/server';
import { middleware, publicProcedure } from './init';
// Auth middleware — checks for valid session
const isAuthed = middleware(async ({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
// Swap context — downstream procedures now have a guaranteed userId
return next({
ctx: { userId: ctx.userId },
});
});
// Logging middleware
const withLogging = middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
console.log(`${type} ${path} — ${Date.now() - start}ms`);
return result;
});
// Create reusable procedure types
export const protectedProcedure = publicProcedure.use(isAuthed);
export const loggedProcedure = publicProcedure.use(withLogging);
Use protectedProcedure instead of publicProcedure for any route that requires authentication:
export const userRouter = router({
me: protectedProcedure.query(async ({ ctx }) => {
// ctx.userId is guaranteed to be a string here
return await db.user.findUnique({ where: { id: ctx.userId } });
}),
});
Error Handling
tRPC provides structured error codes that map to HTTP status codes:
import { TRPCError } from '@trpc/server';
// In any procedure
throw new TRPCError({
code: 'NOT_FOUND', // → 404
message: 'User not found',
});
throw new TRPCError({
code: 'BAD_REQUEST', // → 400
message: 'Invalid email format',
});
throw new TRPCError({
code: 'FORBIDDEN', // → 403
message: 'Admin access required',
});
You can also customize error formatting globally during initialization to include metadata like Zod validation details.
Project Structure
/trpc
├── init.ts # tRPC initialization, context, base procedures
├── router.ts # Root appRouter merging all sub-routers
├── client.tsx # Client provider + hooks
└── routers/
├── dashboard.ts # Dashboard-related procedures
├── user.ts # User CRUD procedures
└── notification.ts
/app
└── api/trpc/[trpc]
└── route.ts # API handler (fetch adapter)
Keep routers small and focused. Each router maps to a domain area — this keeps things organized as your API grows.
When to Use tRPC
Use tRPC when:
-
Your frontend and backend are both TypeScript
-
You're in a monorepo or fullstack framework (Next.js, SvelteKit)
-
You want type safety without codegen overhead
-
You're building internal dashboards, admin panels, or SaaS apps
Skip tRPC when:
-
You need a public API consumed by non-TypeScript clients
-
Your backend is Go, Python, or another non-TS language
-
Simple CRUD where Server Actions are sufficient
-
Microservices where the fullstack TS assumption breaks down
Useful Links
| Resource | URL |
| tRPC Docs | trpc.io/docs |
| tRPC v11 Announcement | trpc.io/blog/announcing-trpc-v11 |
| Next.js App Router Setup | trpc.io/docs/client/nextjs |
| TanStack React Query | tanstack.com/query |
| Zod Validation | zod.dev |
| Migration Guide (v10 → v11) | trpc.io/docs/migrate-from-v10-to-v11 |
Last updated: March 28, 2026