Build End-to-End Type-Safe APIs with tRPC and Next.js App Router

End-to-end type safety without code generation. tRPC lets you call server functions from the client with full TypeScript autocompletion, validation, and error handling — no REST schemas, no GraphQL resolvers, no OpenAPI specs. In this tutorial, you will build a complete task manager with Next.js 15 App Router and tRPC.
What You Will Learn
By the end of this tutorial, you will:
- Set up tRPC v11 with the Next.js 15 App Router using the fetch adapter
- Define queries, mutations, and subscriptions with Zod validation
- Create middleware for authentication and logging
- Integrate TanStack React Query v5 for client-side data fetching
- Use server-side callers in Server Components
- Handle errors with tRPC's built-in error system
- Build a working task manager with full CRUD operations
Prerequisites
Before starting, ensure you have:
- Node.js 20+ installed (
node --version) - TypeScript experience (types, generics, inference)
- Next.js App Router familiarity (Server Components, route handlers)
- React Query basics (optional, we will cover what you need)
- A code editor — VS Code or Cursor recommended
Why tRPC?
If your frontend and backend are both TypeScript, you are already sharing a type system. So why write REST endpoints with separate request/response types, or maintain a GraphQL schema? tRPC eliminates this duplication entirely.
| Feature | REST | GraphQL | tRPC |
|---|---|---|---|
| Type safety | Manual | Code-gen | Automatic |
| Schema definition | OpenAPI | SDL | None needed |
| Learning curve | Low | Medium | Low |
| Bundle size | Varies | Heavy | Minimal |
| Best for | Public APIs | Complex graphs | TS-to-TS apps |
tRPC shines when your client and server share the same TypeScript codebase — which is exactly what Next.js gives you.
Step 1: Create a Next.js Project
Start by scaffolding a new Next.js 15 project:
npx create-next-app@latest trpc-task-manager --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd trpc-task-managerStep 2: Install tRPC and Dependencies
Install the tRPC packages along with TanStack React Query and Zod:
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zodHere is what each package does:
@trpc/server— defines your API router, procedures, and middleware@trpc/client— vanilla TypeScript client for calling your API@trpc/react-query— React hooks wrapping TanStack React Query@tanstack/react-query— powerful async state management for Reactzod— runtime schema validation and TypeScript type inference
Step 3: Initialize the tRPC Backend
Create the tRPC initialization file. This is where you define your context and procedure builders.
Create src/trpc/init.ts:
import { initTRPC, TRPCError } from "@trpc/server";
import { ZodError } from "zod";
// Define the context available to all procedures
export type Context = {
userId: string | null;
};
// Create context for each request
export const createTRPCContext = async (): Promise<Context> => {
// In a real app, extract user from session/JWT here
return {
userId: null,
};
};
// Initialize tRPC — this should only be done once
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError:
error.cause instanceof ZodError ? error.cause.flatten() : null,
},
};
},
});
// Export reusable helpers
export const router = t.router;
export const publicProcedure = t.procedure;
export const createCallerFactory = t.createCallerFactory;
// Middleware: require authentication
const enforceAuth = t.middleware(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
return next({
ctx: {
userId: ctx.userId,
},
});
});
export const protectedProcedure = t.procedure.use(enforceAuth);This file sets up three important things:
- Context — data available to every procedure (like the current user)
- Public procedures — accessible without authentication
- Protected procedures — require a logged-in user
Step 4: Define the Task Router
Now create the actual API logic. Create src/trpc/routers/task.ts:
import { z } from "zod";
import { router, publicProcedure, protectedProcedure } from "../init";
import { TRPCError } from "@trpc/server";
// In-memory store (replace with a real database in production)
interface Task {
id: string;
title: string;
description: string;
completed: boolean;
createdAt: Date;
updatedAt: Date;
}
const tasks: Task[] = [
{
id: "1",
title: "Learn tRPC",
description: "Build a type-safe API with tRPC and Next.js",
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
},
];
// Input validation schemas
const createTaskSchema = z.object({
title: z.string().min(1, "Title is required").max(100),
description: z.string().max(500).default(""),
});
const updateTaskSchema = z.object({
id: z.string(),
title: z.string().min(1).max(100).optional(),
description: z.string().max(500).optional(),
completed: z.boolean().optional(),
});
export const taskRouter = router({
// GET all tasks
list: publicProcedure
.input(
z
.object({
filter: z.enum(["all", "active", "completed"]).default("all"),
})
.optional()
)
.query(({ input }) => {
const filter = input?.filter ?? "all";
if (filter === "active") return tasks.filter((t) => !t.completed);
if (filter === "completed") return tasks.filter((t) => t.completed);
return tasks;
}),
// GET single task by ID
byId: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const task = tasks.find((t) => t.id === input.id);
if (!task) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
return task;
}),
// CREATE a new task
create: publicProcedure
.input(createTaskSchema)
.mutation(({ input }) => {
const newTask: Task = {
id: crypto.randomUUID(),
title: input.title,
description: input.description,
completed: false,
createdAt: new Date(),
updatedAt: new Date(),
};
tasks.push(newTask);
return newTask;
}),
// UPDATE an existing task
update: publicProcedure
.input(updateTaskSchema)
.mutation(({ input }) => {
const index = tasks.findIndex((t) => t.id === input.id);
if (index === -1) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
const updated = {
...tasks[index],
...input,
updatedAt: new Date(),
};
tasks[index] = updated;
return updated;
}),
// DELETE a task
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(({ input }) => {
const index = tasks.findIndex((t) => t.id === input.id);
if (index === -1) {
throw new TRPCError({
code: "NOT_FOUND",
message: `Task with ID ${input.id} not found`,
});
}
const deleted = tasks.splice(index, 1)[0];
return deleted;
}),
});Notice how every input is validated with Zod. If a client sends invalid data, tRPC automatically returns a 400 error with detailed validation messages — and TypeScript catches the mismatch at compile time.
Step 5: Create the App Router
Combine all your routers into a single root router. Create src/trpc/routers/_app.ts:
import { router } from "../init";
import { taskRouter } from "./task";
export const appRouter = router({
task: taskRouter,
});
// Export the type — this is the magic that enables end-to-end type safety
export type AppRouter = typeof appRouter;The AppRouter type is the key. You export this type and import it on the client — no runtime code crosses the boundary, only types. TypeScript infers every input, output, and error from your procedures.
Step 6: Set Up the Route Handler
Connect tRPC to Next.js using the fetch adapter. Create src/app/api/trpc/[trpc]/route.ts:
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { createTRPCContext } from "@/trpc/init";
import { appRouter } from "@/trpc/routers/_app";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/trpc",
req,
router: appRouter,
createContext: createTRPCContext,
});
export { handler as GET, handler as POST };This creates a catch-all route at /api/trpc/*. Every tRPC procedure becomes an endpoint automatically — task.list maps to /api/trpc/task.list.
Step 7: Create the tRPC Client
Now set up the client-side integration. You need two files.
First, create the tRPC React hooks in src/trpc/client.ts:
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "./routers/_app";
export const trpc = createTRPCReact<AppRouter>();Then create the provider in src/trpc/provider.tsx:
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { httpBatchLink } from "@trpc/client";
import { useState } from "react";
import { trpc } from "./client";
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 TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 1000,
retry: 1,
},
},
}));
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${getBaseUrl()}/api/trpc`,
}),
],
})
);
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
</trpc.Provider>
);
}Add the provider to your root layout in src/app/layout.tsx:
import { TRPCProvider } from "@/trpc/provider";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>
<TRPCProvider>{children}</TRPCProvider>
</body>
</html>
);
}Step 8: Build the Task Manager UI
Now the fun part — using tRPC in your components with full type safety.
Create src/app/page.tsx:
"use client";
import { useState } from "react";
import { trpc } from "@/trpc/client";
export default function TaskManager() {
const [filter, setFilter] = useState<"all" | "active" | "completed">("all");
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
// Queries — fully typed, no manual type annotations needed
const tasksQuery = trpc.task.list.useQuery({ filter });
// Mutations with automatic cache invalidation
const utils = trpc.useUtils();
const createTask = trpc.task.create.useMutation({
onSuccess: () => {
utils.task.list.invalidate();
setNewTitle("");
setNewDescription("");
},
});
const updateTask = trpc.task.update.useMutation({
onSuccess: () => utils.task.list.invalidate(),
});
const deleteTask = trpc.task.delete.useMutation({
onSuccess: () => utils.task.list.invalidate(),
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
createTask.mutate({
title: newTitle,
description: newDescription,
});
};
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Task Manager</h1>
{/* Create Task Form */}
<form onSubmit={handleSubmit} className="mb-8 space-y-4">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="Task title..."
className="w-full p-3 border rounded-lg"
/>
<textarea
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder="Description (optional)"
className="w-full p-3 border rounded-lg"
rows={2}
/>
<button
type="submit"
disabled={createTask.isPending}
className="px-6 py-3 bg-blue-600 text-white rounded-lg
hover:bg-blue-700 disabled:opacity-50"
>
{createTask.isPending ? "Adding..." : "Add Task"}
</button>
</form>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
{(["all", "active", "completed"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-4 py-2 rounded-lg capitalize ${
filter === f
? "bg-blue-600 text-white"
: "bg-gray-100 hover:bg-gray-200"
}`}
>
{f}
</button>
))}
</div>
{/* Task List */}
{tasksQuery.isLoading && <p>Loading tasks...</p>}
{tasksQuery.error && (
<p className="text-red-600">Error: {tasksQuery.error.message}</p>
)}
<ul className="space-y-3">
{tasksQuery.data?.map((task) => (
<li
key={task.id}
className="flex items-center gap-4 p-4 border rounded-lg"
>
<input
type="checkbox"
checked={task.completed}
onChange={() =>
updateTask.mutate({
id: task.id,
completed: !task.completed,
})
}
className="w-5 h-5"
/>
<div className="flex-1">
<h3
className={`font-medium ${
task.completed ? "line-through text-gray-400" : ""
}`}
>
{task.title}
</h3>
{task.description && (
<p className="text-sm text-gray-500">{task.description}</p>
)}
</div>
<button
onClick={() => deleteTask.mutate({ id: task.id })}
className="text-red-500 hover:text-red-700"
>
Delete
</button>
</li>
))}
</ul>
{tasksQuery.data?.length === 0 && (
<p className="text-center text-gray-500 py-8">
No tasks found. Create one above.
</p>
)}
</main>
);
}Notice the autocompletion. When you type trpc.task., your editor shows list, byId, create, update, delete. When you type createTask.mutate({, you get autocompletion for title and description with their exact types. This is the magic of tRPC — zero manual type definitions on the client.
Step 9: Server-Side Calls in Server Components
One of tRPC's best features is calling procedures directly from Server Components without HTTP overhead.
Create src/trpc/server.ts:
import { createCallerFactory } from "./init";
import { appRouter } from "./routers/_app";
const createCaller = createCallerFactory(appRouter);
export const serverTRPC = createCaller({
userId: null, // populate from session in real apps
});Now use it in a Server Component. Create src/app/stats/page.tsx:
import { serverTRPC } from "@/trpc/server";
export default async function StatsPage() {
// Direct function call — no HTTP, no serialization overhead
const allTasks = await serverTRPC.task.list({ filter: "all" });
const completedTasks = await serverTRPC.task.list({ filter: "completed" });
const completionRate =
allTasks.length > 0
? Math.round((completedTasks.length / allTasks.length) * 100)
: 0;
return (
<main className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-8">Task Statistics</h1>
<div className="grid grid-cols-3 gap-4">
<div className="p-6 bg-blue-50 rounded-lg text-center">
<p className="text-3xl font-bold text-blue-600">{allTasks.length}</p>
<p className="text-gray-600">Total Tasks</p>
</div>
<div className="p-6 bg-green-50 rounded-lg text-center">
<p className="text-3xl font-bold text-green-600">
{completedTasks.length}
</p>
<p className="text-gray-600">Completed</p>
</div>
<div className="p-6 bg-purple-50 rounded-lg text-center">
<p className="text-3xl font-bold text-purple-600">
{completionRate}%
</p>
<p className="text-gray-600">Completion Rate</p>
</div>
</div>
</main>
);
}The serverTRPC.task.list() call runs directly on the server — same process, same memory, no network hop. TypeScript still enforces the full contract.
Step 10: Add Middleware for Logging
tRPC middleware lets you add cross-cutting concerns like logging, rate limiting, or analytics.
Update src/trpc/init.ts to add a logger middleware:
// Add this after the existing code in init.ts
const logger = t.middleware(async ({ path, type, next }) => {
const start = Date.now();
const result = await next();
const duration = Date.now() - start;
if (result.ok) {
console.log(`[tRPC] ${type} ${path} — ${duration}ms OK`);
} else {
console.error(`[tRPC] ${type} ${path} — ${duration}ms ERROR`);
}
return result;
});
export const loggedProcedure = t.procedure.use(logger);You can stack middleware. A procedure can use both logger and enforceAuth:
export const loggedProtectedProcedure = t.procedure
.use(logger)
.use(enforceAuth);Step 11: Error Handling Best Practices
tRPC provides structured error handling out of the box. Here is how to use it effectively.
Throwing errors in procedures
import { TRPCError } from "@trpc/server";
// In your procedure
throw new TRPCError({
code: "BAD_REQUEST",
message: "Title cannot be empty",
cause: originalError, // optional — for debugging
});Available error codes
| Code | HTTP Status | When to use |
|---|---|---|
BAD_REQUEST | 400 | Invalid input |
UNAUTHORIZED | 401 | Not logged in |
FORBIDDEN | 403 | No permission |
NOT_FOUND | 404 | Resource missing |
CONFLICT | 409 | Duplicate entry |
TOO_MANY_REQUESTS | 429 | Rate limited |
INTERNAL_SERVER_ERROR | 500 | Unexpected error |
Handling errors on the client
const createTask = trpc.task.create.useMutation({
onError: (error) => {
// Zod validation errors
if (error.data?.zodError) {
const fieldErrors = error.data.zodError.fieldErrors;
console.log("Validation errors:", fieldErrors);
return;
}
// tRPC errors
console.log("Error code:", error.data?.code);
console.log("Message:", error.message);
},
});Step 12: Optimistic Updates
For a snappy UI, you can update the cache before the server responds:
const updateTask = trpc.task.update.useMutation({
onMutate: async (newData) => {
// Cancel outgoing refetches
await utils.task.list.cancel();
// Snapshot current data
const previous = utils.task.list.getData({ filter: "all" });
// Optimistically update
utils.task.list.setData({ filter: "all" }, (old) =>
old?.map((task) =>
task.id === newData.id ? { ...task, ...newData } : task
)
);
return { previous };
},
onError: (_err, _newData, context) => {
// Rollback on error
if (context?.previous) {
utils.task.list.setData({ filter: "all" }, context.previous);
}
},
onSettled: () => {
utils.task.list.invalidate();
},
});Testing Your Implementation
Start the development server:
npm run devOpen http://localhost:3000 and verify:
- The task list loads with the seed task
- You can create new tasks with the form
- Clicking the checkbox toggles completion
- The delete button removes tasks
- Filter tabs work correctly
- Visit
/statsto see the server-side rendered statistics
Testing with curl
You can also test the API directly:
# List all tasks
curl "http://localhost:3000/api/trpc/task.list?input=%7B%7D"
# Create a task
curl -X POST "http://localhost:3000/api/trpc/task.create" \
-H "Content-Type: application/json" \
-d '{"json":{"title":"Test from curl","description":"Works!"}}'Project Structure
Here is the final project structure:
src/
├── app/
│ ├── api/trpc/[trpc]/
│ │ └── route.ts # tRPC route handler
│ ├── stats/
│ │ └── page.tsx # Server Component with server-side calls
│ ├── layout.tsx # Root layout with TRPCProvider
│ └── page.tsx # Task manager UI
└── trpc/
├── client.ts # React hooks (createTRPCReact)
├── init.ts # tRPC initialization, context, middleware
├── provider.tsx # Client-side provider
├── server.ts # Server-side caller
└── routers/
├── _app.ts # Root router
└── task.ts # Task procedures
Troubleshooting
"Cannot find module" errors
Make sure your tsconfig.json has path aliases configured:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
}
}"Hydration mismatch" warnings
Ensure your TRPCProvider is marked with "use client" and wraps only the parts of your app that need client-side tRPC hooks.
Stale data after mutations
Always call utils.task.list.invalidate() in onSuccess or onSettled to refetch after a mutation.
Type errors after changing procedures
If you change a procedure's input/output, TypeScript may cache stale types. Restart your TypeScript server (Cmd+Shift+P → "TypeScript: Restart TS Server" in VS Code).
Next Steps
Now that you have a working tRPC setup, consider these enhancements:
- Add a real database — replace the in-memory store with Drizzle ORM and PostgreSQL
- Add authentication — integrate AuthJS v5 and populate
ctx.userId - Add real-time updates — use tRPC subscriptions with WebSockets
- Add testing — use
createCallerFactoryfor unit testing procedures - Deploy — containerize with Docker or deploy to Vercel
Conclusion
tRPC fundamentally changes how you build APIs in TypeScript applications. Instead of maintaining separate type definitions for your client and server, you write your procedures once and let TypeScript infer everything. The result is:
- Fewer bugs — type mismatches are caught at compile time, not in production
- Faster development — autocompletion for every API call, no manual type definitions
- Less code — no REST boilerplate, no GraphQL resolvers, no code generation step
- Better DX — rename a field on the server and TypeScript immediately flags every client usage
Combined with Next.js App Router, you get the best of both worlds: server-side rendering with zero-overhead server calls, and client-side interactivity with full type safety. This is the stack that makes full-stack TypeScript development truly seamless.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build a Full-Stack App with Drizzle ORM and Next.js 15: Type-Safe Database from Zero to Production
Learn how to build a type-safe full-stack application using Drizzle ORM with Next.js 15. This hands-on tutorial covers schema design, migrations, Server Actions, CRUD operations, and deployment with PostgreSQL.

Building an Autonomous AI Agent with Agentic RAG and Next.js
Learn how to build an AI agent that autonomously decides when and how to retrieve information from vector databases. A comprehensive hands-on guide using Vercel AI SDK and Next.js with executable examples.

Build and Deploy a Serverless API with Cloudflare Workers, Hono, and D1
Learn how to build a production-ready serverless REST API using Cloudflare Workers, the Hono web framework, and D1 SQLite database — from project setup to global deployment.