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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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.

FeatureRESTGraphQLtRPC
Type safetyManualCode-genAutomatic
Schema definitionOpenAPISDLNone needed
Learning curveLowMediumLow
Bundle sizeVariesHeavyMinimal
Best forPublic APIsComplex graphsTS-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-manager

Step 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 zod

Here 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 React
  • zod — 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:

  1. Context — data available to every procedure (like the current user)
  2. Public procedures — accessible without authentication
  3. 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

CodeHTTP StatusWhen to use
BAD_REQUEST400Invalid input
UNAUTHORIZED401Not logged in
FORBIDDEN403No permission
NOT_FOUND404Resource missing
CONFLICT409Duplicate entry
TOO_MANY_REQUESTS429Rate limited
INTERNAL_SERVER_ERROR500Unexpected 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 dev

Open http://localhost:3000 and verify:

  1. The task list loads with the seed task
  2. You can create new tasks with the form
  3. Clicking the checkbox toggles completion
  4. The delete button removes tasks
  5. Filter tabs work correctly
  6. Visit /stats to 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 createCallerFactory for 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.


Want to read more tutorials? Check out our latest tutorial on Building Multi-Agent AI Systems with n8n: A Comprehensive Guide to Intelligent Automation.

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