Skip to content
Learni
View all tutorials
Développement Fullstack

How to Implement tRPC in Next.js in 2026

Lire en français

Introduction

In 2026, tRPC is the go-to tool for end-to-end type-safe APIs in TypeScript. Unlike traditional REST or GraphQL, tRPC compiles your server procedures into inferred client types, eliminating runtime errors from type mismatches. Imagine an API where every request is validated at compile time: no any types, no messy JSON.parse, and perfect IDE autocompletion.

This intermediate tutorial walks you through integrating tRPC v11+ into Next.js 15+ with App Router. We'll build a complete todo app: queries to list tasks, mutations for CRUD operations, all powered by React Query for caching and optimistic updates. The result: a scalable, lightning-fast API with zero-config auth or middleware. Ideal for boosting full-stack TS productivity. Ready to level up? (128 words)

Prerequisites

  • Node.js 20+ and npm/yarn/pnpm
  • Knowledge of Next.js App Router and TypeScript
  • Familiarity with React Query (TanStack Query)
  • An editor like VS Code with ESLint/Prettier set up
  • 10 minutes for a working project

Set Up the Next.js + tRPC Project

terminal
npx create-next-app@latest mon-app-trpc --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd mon-app-trpc
npm install @trpc/server@latest @trpc/client@latest @trpc/react-query@latest @trpc/next@latest @tanstack/react-query@latest superjson@latest zod@latest
npm install -D @types/node typescript

This command creates an optimized Next.js 15+ project with App Router and Tailwind. We install tRPC core, client, React integration with React Query for caching, superjson for advanced serialization (dates, BigInt), zod for validation, and Node types. Run npm run dev afterward to verify.

tRPC Project Structure

tRPC uses a hierarchical router: a root.ts assembles sub-routers. The API handler is a single endpoint at /api/trpc/[trpc]. On the client, a Provider wraps the app. We'll use an in-memory store for todos as a lightweight DB. Create these folders:

  • src/trpc/routers/ for procedures
  • src/trpc/ for root and client

Analogy: tRPC is like native JS RPC, but type-safe—no unnecessary HTTP overhead.

Create the Root Router and In-Memory Store

src/trpc/root.ts
import { initTRPC, TRPCError } from '@trpc/server';
import { createHTTPServer } from '@trpc/server/adapters/standalone';
import superjson from 'superjson';
import { ZodError } from 'zod';
import { todoRouter } from './routers/todo';

const t = initTRPC.context<{ todos: { id: number; text: string; done: boolean }[] }>().create({
  transformer: superjson,
  errorFormatter({ shape }) {
    return { ...shape, data: { ...shape.data, zodError: shape.data?.zodError?.fieldErrors } };
  },
});

export const appRouter = t.router({
  todo: todoRouter,
});

export type AppRouter = typeof appRouter;

const todos: { id: number; text: string; done: boolean }[] = [];

// Handler pour dev standalone (optionnel)
createHTTPServer({
  router: appRouter,
  createContext: () => ({ todos }),
}).listen(3001);

console.log('🚀 tRPC standalone server à http://localhost:3001');

This file sets up the tRPC core with superjson for perfect serialization. The context shares the in-memory todos store. appRouter assembles sub-routers. We add a standalone server for isolated testing (port 3001). Avoid empty contexts: always type them for client autocompletion.

Define the Todo Router with Queries and Mutations

src/trpc/routers/todo.ts
import { z } from 'zod';
import { publicProcedure, router } from '../root';

export const todoRouter = router({
  all: publicProcedure
    .query(({ ctx }) => {
      return { todos: ctx.todos };
    }),
  byId: publicProcedure
    .input(z.object({ id: z.number() }))
    .query(({ input, ctx }) => {
      const todo = ctx.todos.find((t) => t.id === input.id);
      if (!todo) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      return { todo };
    }),
  create: publicProcedure
    .input(z.object({ text: z.string().min(1) }))
    .mutation(({ input, ctx }) => {
      const newTodo = { id: Date.now(), text: input.text, done: false };
      ctx.todos.push(newTodo);
      return { todo: newTodo };
    }),
  toggle: publicProcedure
    .input(z.object({ id: z.number() }))
    .mutation(({ input, ctx }) => {
      const todo = ctx.todos.find((t) => t.id === input.id);
      if (!todo) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      todo.done = !todo.done;
      return { todo };
    }),
  delete: publicProcedure
    .input(z.object({ id: z.number() }))
    .mutation(({ input, ctx }) => {
      const index = ctx.todos.findIndex((t) => t.id === input.id);
      if (index === -1) {
        throw new TRPCError({ code: 'NOT_FOUND' });
      }
      ctx.todos.splice(index, 1);
      return { deleted: true };
    }),
});

Each procedure uses Zod to validate inputs/outputs, automatically inferring types. publicProcedure has no auth (extend with middlewares). Queries for reads, mutations for writes with type-safe TRPCError handling. Directly mutating the store simulates a DB; in production, use Prisma or Drizzle.

Next.js API Handler for tRPC

Single entry point: All procedures go through /api/trpc/[trpc]. This enables request batching and optimizes performance. The fetch handler uses the same context as our store.

Create the tRPC API Handler

app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/trpc/root';
import { createNextApiHandler } from '@trpc/server/adapters/next';

const todos: { id: number; text: string; done: boolean }[] = [];

const handler = createNextApiHandler({
  router: appRouter,
  createContext: () => ({ todos }),
});

export { handler as GET, handler as POST };

This App Router handler exposes tRPC via GET/POST. Shares the same in-memory store. createNextApiHandler handles batching and auto-CORS. Test with curl http://localhost:3000/api/trpc/todo.all. Pitfall: Sync the store across multi-instances (use Redis in prod).

Client-Side tRPC + React Query Setup

On the client, createTRPCReact generates type-safe hooks. Wrap with QueryClient and TRPCProvider in layout.tsx. Types like AppRouter are inferred from the server.

tRPC Client Hooks and Provider

src/trpc/react.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { httpBatchLink } from '@trpc/client';
import { createTRPCReact } from '@trpc/react-query';
import type { AppRouter } from './root';
import superjson from 'superjson';

export const api = createTRPCReact<AppRouter, any, null>();

export function TRPCReactProvider(props: { children: React.ReactNode }) {
  const queryClient = React.useState(() => new QueryClient())[0];
  const [trpcClient] = React.useState(() =>
    api.createClient({
      transformer: superjson,
      links: [
        httpBatchLink({
          url: 'http://localhost:3000/api/trpc',
        }),
      ],
    }),
  );
  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
}

This module creates api for hooks like api.todo.all.useQuery(). httpBatchLink batches calls for 10x perf gains. Provider manages global React Query. In production, add WebSocketLink for subscriptions. Tip: Use localhost for dev, env var for prod.

Integrate the Provider in the Layout

app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { TRPCReactProvider } from '@/trpc/react';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'tRPC Next.js Demo',
  description: 'App Todo avec tRPC type-safe',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="fr">
      <body className={inter.className}>
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

The Provider wraps the entire app for global hooks. SEO-ready metadata. Restart npm run dev: tRPC is ready! Without it, hooks fail silently.

Todo Component Using tRPC

Now, a full-stack component: list, add, toggle, delete. Hooks infer types, suspense for loading, optimistic updates for smooth UX.

Main Page with Todo CRUD

app/page.tsx
import { api } from '@/trpc/react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Suspense } from 'react';

function TodoList() {
  const { data: todos, isLoading } = api.todo.all.useQuery();
  const utils = api.useUtils();
  const createMutation = api.todo.create.useMutation({
    onMutate: async (newTodo) => {
      await utils.todo.all.invalidate();
    },
  });
  const toggleMutation = api.todo.toggle.useMutation();
  const deleteMutation = api.todo.delete.useMutation();
  const [newText, setNewText] = React.useState('');
  if (isLoading) return <div>Chargement...</div>;
  return (
    <div className="p-8 max-w-md mx-auto">
      <h1 className="text-2xl font-bold mb-4">Mes Todos tRPC</h1>
      <div className="flex gap-2 mb-4">
        <Input
          value={newText}
          onChange={(e) => setNewText(e.target.value)}
          placeholder="Nouvelle todo"
        />
        <Button
          onClick={() => {
            createMutation.mutate({ text: newText });
            setNewText('');
          }}
        >
          Ajouter
        </Button>
      </div>
      <ul>
        {todos?.todos.map((todo) => (
          <li key={todo.id} className="flex gap-2 items-center mb-2 p-2 border rounded">
            <input
              type="checkbox"
              checked={todo.done}
              onChange={() => toggleMutation.mutate({ id: todo.id })}
            />
            <span className={todo.done ? 'line-through' : ''}>{todo.text}</span>
            <Button
              variant="destructive"
              size="sm"
              onClick={() => deleteMutation.mutate({ id: todo.id })}
            >
              Supprimer
            </Button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function Page() {
  return (
    <Suspense fallback="Chargement des todos...">
      <TodoList />
    </Suspense>
  );
}

Hooks like useQuery/useMutation are fully typed. invalidate() refreshes related queries. Suspense handles global loading. Add Shadcn/UI for Button/Input (npx shadcn-ui@latest init). Test it: live todo app! Optimistic updates via onMutate for native UX.

Best Practices

  • Separate routers by feature: userRouter, postRouter for scalability.
  • Use middlewares: t.middleware(({ next }) => next({ ctx: { user: getUser() } })) for auth.
  • Enable batching: Automatic with httpBatchLink, reduces roundtrips.
  • DB migrations: Pair with Prisma + tRPC generator for auto types.
  • Observability: Integrate Sentry for TRPCError tracking.

Common Errors to Avoid

  • Unshared store: Different handler/dev contexts → lost data; use singleton or DB.
  • Forget transformer: Without superjson, Dates/BigInt break.
  • No Zod: Unvalidated inputs → zero security; always .input(z.schema()).
  • Missing Provider: Hooks become undefined; wrap in layout.

Next Steps