Building a Full-Stack Type-Safe API with tRPC and Next.js
A complete tutorial on end-to-end type-safe APIs with tRPC and the Next.js App Router: router setup, middleware, input validation and subscriptions.

End-to-End Type Safety: Why It Matters
You define an API endpoint that returns a user object. The backend team adds a field. The frontend team doesn't know. The app crashes in production at 2 AM because someone accessed a property that no longer exists.
This scenario is the natural consequence of separately typed client and server code. REST APIs have no type connection between the handler that produces data and the component that consumes it. GraphQL adds schema types but requires code generation to bridge the gap. tRPC eliminates the gap entirely—your backend types are your frontend types, with zero code generation.
This tutorial builds a full-stack application with tRPC and Next.js App Router, showing how type safety flows from database queries through API procedures to React components.
Setting Up the tRPC Router
The router is tRPC's core abstraction. It defines procedures (queries, mutations, subscriptions) with input validation and middleware chains.
// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { z } from "zod";
import superjson from "superjson";
import { getServerSession } from "next-auth";
interface Context {
session: { user: { id: string; email: string; role: string } } | null;
db: typeof prisma;
}
export async function createContext(): Promise<Context> {
const session = await getServerSession();
return {
session,
db: prisma,
};
}
const t = initTRPC.context<Context>().create({
transformer: superjson, // Handles Date, Map, Set serialization
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof z.ZodError
? error.cause.flatten()
: null,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
export const middleware = t.middleware;The context factory runs on every request, providing the session and database client. The superjson transformer handles types that JSON cannot represent natively—Date objects, Maps, Sets, and BigInts all serialize and deserialize correctly.
Middleware Chains for Authentication and Authorization
tRPC middleware composes cleanly. Each middleware can modify the context, validate preconditions, or short-circuit with an error.
// src/server/middleware.ts
import { TRPCError } from "@trpc/server";
import { middleware, publicProcedure } from "./trpc";
const isAuthenticated = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in",
});
}
return next({
ctx: {
...ctx,
session: ctx.session, // Narrowed type: session is non-null
},
});
});
const isAdmin = middleware(async ({ ctx, next }) => {
if (!ctx.session?.user || ctx.session.user.role !== "admin") {
throw new TRPCError({
code: "FORBIDDEN",
message: "Admin access required",
});
}
return next({ ctx });
});
// Composable procedures with middleware stacking
export const protectedProcedure = publicProcedure.use(isAuthenticated);
export const adminProcedure = publicProcedure
.use(isAuthenticated)
.use(isAdmin);After isAuthenticated runs, the context type narrows—downstream procedures know the session is non-null. This is the type safety advantage: the middleware modifies both runtime behavior and compile-time types simultaneously.
Building Domain Routers
Each domain gets its own router with procedures for queries and mutations.
// src/server/routers/posts.ts
import { z } from "zod";
import { router, publicProcedure } from "../trpc";
import { protectedProcedure, adminProcedure } from "../middleware";
const createPostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10).max(50000),
tags: z.array(z.string()).min(1).max(10),
published: z.boolean().default(false),
});
const listPostsSchema = z.object({
cursor: z.string().optional(),
limit: z.number().min(1).max(100).default(20),
tag: z.string().optional(),
});
export const postsRouter = router({
list: publicProcedure
.input(listPostsSchema)
.query(async ({ input, ctx }) => {
const posts = await ctx.db.post.findMany({
where: input.tag ? { tags: { has: input.tag } } : undefined,
take: input.limit + 1,
cursor: input.cursor ? { id: input.cursor } : undefined,
orderBy: { createdAt: "desc" },
include: { author: { select: { name: true, image: true } } },
});
let nextCursor: string | undefined;
if (posts.length > input.limit) {
const extra = posts.pop()!;
nextCursor = extra.id;
}
return { posts, nextCursor };
}),
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
include: {
author: { select: { name: true, image: true } },
comments: {
include: { author: { select: { name: true } } },
orderBy: { createdAt: "asc" },
},
},
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
return post;
}),
create: protectedProcedure
.input(createPostSchema)
.mutation(async ({ input, ctx }) => {
return ctx.db.post.create({
data: {
...input,
authorId: ctx.session.user.id,
},
});
}),
delete: protectedProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input, ctx }) => {
const post = await ctx.db.post.findUnique({
where: { id: input.id },
});
if (!post) {
throw new TRPCError({ code: "NOT_FOUND" });
}
if (post.authorId !== ctx.session.user.id) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return ctx.db.post.delete({ where: { id: input.id } });
}),
});Every input is validated through Zod schemas at the procedure boundary. Invalid requests never reach your database queries. The type information flows automatically—no manual type definitions for request or response shapes.
Merging Routers and Creating the API Handler
// src/server/routers/index.ts
import { router } from "../trpc";
import { postsRouter } from "./posts";
import { usersRouter } from "./users";
import { commentsRouter } from "./comments";
export const appRouter = router({
posts: postsRouter,
users: usersRouter,
comments: commentsRouter,
});
export type AppRouter = typeof appRouter;// src/app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/server/routers";
import { createContext } from "@/server/trpc";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext,
});
export { handler as GET, handler as POST };The AppRouter type export is the bridge. The client imports this type—not the implementation—and gets complete type inference for every procedure.
Client Setup with React Query Integration
tRPC v11 integrates with TanStack Query (React Query) for caching, refetching, optimistic updates, and infinite queries.
// src/lib/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "@/server/routers";
export const trpc = createTRPCReact<AppRouter>();// src/app/providers.tsx
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { trpc } from "@/lib/trpc";
import { useState } from "react";
import superjson from "superjson";
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient());
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: "/api/trpc",
transformer: superjson,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}The httpBatchLink automatically batches multiple tRPC calls in the same render cycle into a single HTTP request. Three useQuery hooks that fire simultaneously result in one network request, not three.
Consuming Procedures in Components
// ❌ Bad: Untyped fetch with manual types
async function loadPosts() {
const res = await fetch("/api/posts?limit=20");
const data: any = await res.json(); // No type safety
return data.posts; // Might not exist
}// ✅ Good: Fully typed tRPC query
"use client";
import { trpc } from "@/lib/trpc";
function PostsList() {
const { data, isLoading, error, fetchNextPage, hasNextPage } =
trpc.posts.list.useInfiniteQuery(
{ limit: 20 },
{
getNextPageParam: (lastPage) => lastPage.nextCursor,
}
);
if (isLoading) return <PostsSkeleton />;
if (error) return <ErrorDisplay message={error.message} />;
const allPosts = data.pages.flatMap((page) => page.posts);
return (
<div>
{allPosts.map((post) => (
<PostCard
key={post.id}
title={post.title}
// TypeScript knows exactly what fields exist
author={post.author.name}
createdAt={post.createdAt} // Date object, not string
/>
))}
{hasNextPage && (
<button onClick={() => fetchNextPage()}>Load more</button>
)}
</div>
);
}Hover over post in your editor—TypeScript knows the exact shape, including nested relations. Rename a field in the Prisma schema, and the compiler shows every component that needs updating. No stale types, no runtime surprises.
Optimistic Updates for Mutations
function CreateComment({ postId }: { postId: string }) {
const utils = trpc.useUtils();
const createComment = trpc.comments.create.useMutation({
onMutate: async (newComment) => {
await utils.posts.byId.cancel({ id: postId });
const previousData = utils.posts.byId.getData({ id: postId });
utils.posts.byId.setData({ id: postId }, (old) => {
if (!old) return old;
return {
...old,
comments: [
...old.comments,
{
id: "temp-id",
content: newComment.content,
author: { name: "You" },
createdAt: new Date(),
},
],
};
});
return { previousData };
},
onError: (_err, _vars, context) => {
if (context?.previousData) {
utils.posts.byId.setData({ id: postId }, context.previousData);
}
},
onSettled: () => {
utils.posts.byId.invalidate({ id: postId });
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
const form = new FormData(e.currentTarget);
createComment.mutate({
postId,
content: form.get("content") as string,
});
}}
>
<textarea name="content" required />
<button type="submit" disabled={createComment.isPending}>
Comment
</button>
</form>
);
}Optimistic updates show changes instantly while the mutation runs in the background. If the mutation fails, the onError handler rolls back to the previous state. The onSettled handler refetches the real data regardless of success or failure.
Key Takeaways
tRPC collapses the gap between backend and frontend types into zero-generation-step type safety. Change a return type in your procedure, and TypeScript immediately flags every consuming component. This is not incremental improvement—it is a fundamentally different development experience.
The stack—tRPC + Zod + Prisma + TanStack Query—gives you type safety from database schema through API validation to component rendering. Each layer infers types from the previous one, creating a chain where a schema change ripples through the entire application at compile time.
The tradeoff is coupling. tRPC works best when your frontend and backend live in the same TypeScript project. For public APIs consumed by third parties, REST or GraphQL with generated SDK types is still the right choice. For full-stack applications owned by a single team, tRPC eliminates an entire category of bugs.


