TypeScript has transformed type safety on the frontend, but APIs have long been the weakest link in the full-stack type chain. REST APIs require manual type definitions that drift from the server implementation over time. GraphQL solves part of this but introduces schema files and code generation pipelines that add friction to everyday development.
tRPC takes a fundamentally different approach: if both your client and server are TypeScript, why define an interface contract at all? Instead of a schema, tRPC uses TypeScript's structural type inference to automatically propagate types from your router definitions directly to your client code β no schemas, no codegen steps, no separate type-sync scripts to maintain.
This article covers tRPC's architecture, how to integrate it with a Next.js App Router project, building type-safe procedures with Zod validation, authentication middleware, real-time subscriptions, and unit-testing procedures without spinning up a server.
tRPC's core insight is that a TypeScript function's input and output types already constitute a complete API contract. The library captures those types at the router level and exports them as a single AppRouter type that the client imports directly β at the type level only.
flowchart LR
A["Server: Router Definition\n(input/output types)"] -->|"Exports AppRouter type"| B["Type-only import\n(zero runtime bytes)"]
B --> C["Client: trpc.post.list.useQuery(...)"]
C -->|"Full IntelliSense"| D["Autocomplete &\ncompile-time errors"]
The critical detail: only the type of AppRouter crosses the client boundary. The router implementation code never ships to the browser. You get full type coverage with zero bundle-size cost.
Every tRPC API is built from three procedure types:
| Procedure | HTTP Equivalent | Use Case |
|---|---|---|
query |
GET | Read data, safe to cache |
mutation |
POST/PUT/DELETE | Writes or side-effect operations |
subscription |
WebSocket | Real-time streaming data |
Procedures are grouped into routers, which can be nested to create namespaced APIs that mirror your domain model.
tRPC is a strong fit when:
Prefer REST or GraphQL when:
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
A recommended layout for a Next.js project:
src/
βββ server/
β βββ trpc.ts # tRPC instance, context, and middleware
β βββ routers/
β βββ _app.ts # Root router β merges all sub-routers
β βββ post.ts
β βββ user.ts
βββ app/
β βββ api/trpc/[trpc]/
β β βββ route.ts # Next.js App Router HTTP handler
β βββ _trpc/
β βββ client.ts # Client-side tRPC hooks
The initTRPC call establishes your context type and is the foundation everything else builds on.
// src/server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { type NextRequest } from 'next/server';
import { ZodError } from 'zod';
import superjson from 'superjson';
// Context is created fresh on every request
export const createTRPCContext = async (opts: { req: NextRequest }) => {
const session = await getSessionFromRequest(opts.req);
return {
req: opts.req,
session,
db, // your database client (Prisma, Drizzle, etc.)
};
};
type Context = Awaited<ReturnType<typeof createTRPCContext>>;
const t = initTRPC.context<Context>().create({
transformer: superjson, // handles Date, Map, Set over JSON transparently
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Surface Zod validation errors to the client
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Middleware that enforces authentication
const enforceUserIsAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: {
...ctx,
// Re-typed so downstream procedures see a non-nullable session
session: { ...ctx.session, user: ctx.session.user },
},
});
});
export const protectedProcedure = t.procedure.use(enforceUserIsAuthed);
// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '~/server/routers/_app';
import { createTRPCContext } from '~/server/trpc';
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => createTRPCContext({ req: req as any }),
onError:
process.env.NODE_ENV === 'development'
? ({ path, error }) =>
console.error(`tRPC error on ${path ?? '<no-path>'}:`, error)
: undefined,
});
export { handler as GET, handler as POST };
Zod schemas serve as both runtime validators and compile-time TypeScript type sources simultaneously β you write the schema once and get both for free.
// src/server/routers/post.ts
import { z } from 'zod';
import { router, publicProcedure, protectedProcedure } from '../trpc';
import { TRPCError } from '@trpc/server';
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
published: z.boolean().default(false),
tags: z.array(z.string()).max(5).optional(),
});
export const postRouter = router({
// Paginated list β open to all
list: publicProcedure
.input(
z.object({
limit: z.number().min(1).max(100).default(20),
cursor: z.string().optional(), // cursor-based pagination
})
)
.query(async ({ ctx, input }) => {
const posts = await ctx.db.post.findMany({
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: 'desc' },
where: { published: true },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const nextItem = posts.pop();
nextCursor = nextItem?.id;
}
return { posts, nextCursor };
}),
// Single post by slug
bySlug: publicProcedure
.input(z.object({ slug: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.db.post.findUnique({ where: { slug: input.slug } });
if (!post) {
throw new TRPCError({ code: 'NOT_FOUND', message: 'Post not found' });
}
return post;
}),
// Create post β requires auth
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.post.create({
data: {
...input,
authorId: ctx.session.user.id,
slug: slugify(input.title),
},
});
}),
});
// src/server/routers/_app.ts
import { router } from '../trpc';
import { postRouter } from './post';
import { userRouter } from './user';
export const appRouter = router({
post: postRouter,
user: userRouter,
});
// This single type is the only thing the client imports
export type AppRouter = typeof appRouter;
// src/app/_trpc/client.ts
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from '~/server/routers/_app';
export const trpc = createTRPCReact<AppRouter>();
// src/app/providers.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink, loggerLink } from '@trpc/client';
import { useState } from 'react';
import { trpc } from './_trpc/client';
import superjson from 'superjson';
export function TRPCReactProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({ enabled: () => process.env.NODE_ENV === 'development' }),
httpBatchLink({
// Batches concurrent requests into a single HTTP round-trip
url: '/api/trpc',
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
);
}
// src/app/posts/page.tsx
'use client';
import { trpc } from '../_trpc/client';
export default function PostsPage() {
const utils = trpc.useUtils();
const { data, fetchNextPage, hasNextPage, isLoading } =
trpc.post.list.useInfiniteQuery(
{ limit: 10 },
{ getNextPageParam: (lastPage) => lastPage.nextCursor }
);
const createPost = trpc.post.create.useMutation({
onSuccess: () => {
// Invalidate the list cache so new post appears immediately
utils.post.list.invalidate();
},
});
if (isLoading) return <div>Loadingβ¦</div>;
return (
<div>
{data?.pages.flatMap((page) => page.posts).map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content.slice(0, 150)}β¦</p>
</article>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
<button
onClick={() =>
createPost.mutate({ title: 'Hello tRPC', content: 'First tRPC post content here.' })
}
>
New Post
</button>
</div>
);
}
TypeScript errors at compile time if you pass an invalid input type, misspell a procedure name, or access a non-existent field on the return value β all with zero hand-written type annotations.
tRPC supports WebSocket subscriptions via the observable primitive for live updates:
// src/server/routers/notification.ts
import { observable } from '@trpc/server/observable';
import EventEmitter from 'events';
const ee = new EventEmitter();
export const notificationRouter = router({
onNewMessage: protectedProcedure
.input(z.object({ roomId: z.string() }))
.subscription(({ input }) => {
return observable<{ message: string; from: string }>((emit) => {
const handler = (data: { message: string; from: string; roomId: string }) => {
if (data.roomId === input.roomId) {
emit.next({ message: data.message, from: data.from });
}
};
ee.on('message', handler);
// Return cleanup function β called on unsubscribe
return () => ee.off('message', handler);
});
}),
});
On the client, subscribe with:
trpc.notification.onNewMessage.useSubscription(
{ roomId: 'room-1' },
{ onData: (msg) => console.log('New message:', msg) }
);
In Next.js App Router, Server Components can call procedures directly β no HTTP round-trip involved:
// src/app/posts/[slug]/page.tsx (Server Component β no 'use client')
import { createCallerFactory } from '@trpc/server';
import { appRouter } from '~/server/routers/_app';
import { createTRPCContext } from '~/server/trpc';
const createCaller = createCallerFactory(appRouter);
export default async function PostPage({ params }: { params: { slug: string } }) {
const ctx = await createTRPCContext({ req: /* server request */ } as any);
const caller = createCaller(ctx);
// Direct function call β middleware still runs, auth is still enforced
const post = await caller.post.bySlug({ slug: params.slug });
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
This pattern avoids the overhead of an HTTP request and still runs all your middleware (auth checks, logging, etc.) because it calls the same procedure logic directly.
The createCallerFactory API makes unit-testing procedures fast and straightforward:
// src/server/routers/post.test.ts
import { createCallerFactory } from '@trpc/server';
import { appRouter } from './_app';
import { createInnerTRPCContext } from '../trpc';
const createCaller = createCallerFactory(appRouter);
describe('post router', () => {
it('returns paginated posts for public users', async () => {
const caller = createCaller(createInnerTRPCContext({ session: null }));
const result = await caller.post.list({ limit: 5 });
expect(result.posts).toHaveLength(5);
expect(result.nextCursor).toBeDefined();
});
it('throws UNAUTHORIZED when creating a post without a session', async () => {
const caller = createCaller(createInnerTRPCContext({ session: null }));
await expect(
caller.post.create({ title: 'Test', content: 'Content here for testing.' })
).rejects.toMatchObject({ code: 'UNAUTHORIZED' });
});
it('creates a post for authenticated users', async () => {
const caller = createCaller(
createInnerTRPCContext({ session: { user: { id: 'user-1' } } })
);
const post = await caller.post.create({
title: 'My First tRPC Post',
content: 'This is the body of the post, long enough to pass validation.',
});
expect(post.authorId).toBe('user-1');
expect(post.slug).toBe('my-first-trpc-post');
});
});
tRPC is a pragmatic answer to a real pain point in TypeScript monorepos: the gap between what your server returns and what your client knows about it. By treating the router itself as the contract, you eliminate an entire category of runtime errors and keep your type definitions from drifting.
The tradeoffs are deliberate and honest β tRPC works best in closed systems where both sides are TypeScript. It is not a replacement for REST or GraphQL when you need to serve external consumers or non-TypeScript clients. But for product teams building TypeScript full-stack apps, the developer experience gains are substantial:
A natural next step is integrating tRPC with Drizzle ORM or Prisma for a fully type-safe database layer, and combining it with NextAuth.js or Lucia for session management β both of which fit cleanly into the createTRPCContext pattern shown above.
10275 words authored by Gen-AI! So please do not take it seriously, it's just for fun!