In modern web development, ensuring type consistency between the backend and frontend can be a significant challenge. APIs often feel like a loosely-typed contract, leading to runtime errors and a disconnected developer experience. Enter tRPC (TypeScript Remote Procedure Call), a library that revolutionizes this by allowing you to build and consume fully type-safe APIs without schemas or code generation.

When combined with the power of Next.js App Router and Server Components, tRPC creates a remarkably seamless and robust development workflow. You can call your backend procedures from your server components with the same ease and type-safety as calling a local function.

This guide will walk you through setting up tRPC in a fresh Next.js project, explaining each concept in detail, and culminating in calling your first procedure directly from a Server Component. 🚀

Step 1: Project Initialization

First things first, let's bootstrap a new Next.js project. We'll use the latest version to ensure we have access to all the modern features of the App Router.

Open your terminal and run the following command in your desired directory:

pnpx create-next-app@latest .

Follow the prompts to configure your project. For this guide, we'll be using TypeScript and the App Router.

Once the project is created, you can start the development server to ensure everything is working correctly:

pnpm run dev

You should see the default Next.js welcome page at http://localhost:3000.

Step 2: Installing Core Dependencies

Next, we'll install the essential packages for our tRPC setup.

pnpm add @trpc/server zod superjson

Let's break down what each of these does:

  • @trpc/server: This is the core package that provides the tools to build our tRPC router and procedures on the server.

  • zod: A powerful TypeScript-first schema declaration and validation library. We'll use it to validate the inputs to our API procedures. The magic of Zod is that it infers static TypeScript types from your validation schemas, eliminating the need to declare types twice.

  • superjson: While native JSON is great, it has limitations (e.g., it can't serialize Date objects, Map, Set, or BigInt). superjson extends JSON, allowing us to seamlessly transfer rich JavaScript data structures between the server and client without manual conversion.

Step 3: Setting Up the tRPC Core (src/server/trpc.ts)

This is the heart of our tRPC setup. We will configure the tRPC instance, define context creation, create reusable procedures, and set up middleware.

Create a new file at src/server/trpc.ts and add the following code:

import { initTRPC, TRPCError } from "@trpc/server";
import superjson from "superjson";
import { treeifyError, ZodError } from "zod";
import { getSession } from "./auth"; // Note: getSession is a placeholder for your auth logic

/**
 * The function createTRPCContext creates a context that can hold shared
 * resources like a Database instance or session information.
 * It's available in all tRPC procedures.
 */
export const createTRPCContext = (opts: { headers: Headers }) => {
  return {
    ...opts,
  };
};

/**
 * This code initializes a tRPC instance using the `initTRPC` function.
 * We're providing our context type and configuring global options.
 */
const trpcInstance = initTRPC.context<typeof createTRPCContext>().create({
  transformer: superjson,
  errorFormatter: ({ shape, error }) => ({
    ...shape,
    data: {
      ...shape.data,
      zodError:
        error.cause instanceof ZodError ? treeifyError(error.cause) : null,
    },
  }),
});

/**
 * createCallerFactory is used to instantiate and call our tRPC procedures
 * from server-side code, which is perfect for Next.js Server Components.
 */
export const { createCallerFactory } = trpcInstance;

/**
 * createTRPCRouter is a helper for creating a router instance that groups
 * various procedures (queries, mutations, subscriptions).
 */
export const createTRPCRouter = trpcInstance.router;

/**
 * Middleware logs the time taken by every request to execute,
 * which is invaluable for debugging and performance monitoring.
 */
const timingMiddleware = trpcInstance.middleware(async ({ next, path }) => {
  const start = Date.now();
  const result = await next();
  const end = Date.now();
  console.log(`tRPC procedure '${path}' took ${end - start}ms to execute`);
  return result;
});

/**
 * A public tRPC procedure.
 * Any procedure built on this will be publicly accessible and will
 * automatically have its execution time logged by our middleware.
 */
export const publicProcedure = trpcInstance.procedure.use(timingMiddleware);

/**
 * A protected tRPC procedure for authenticated requests.
 * It chains our timing middleware and adds another middleware layer
 * to verify the user's session.
 */
export const protectedProcedure = trpcInstance.procedure
  .use(timingMiddleware)
  .use(async ({ ctx, next }) => {
    // Note: You would replace getSession with your actual authentication logic.
    const session = await getSession({ headers: ctx.headers });
    if (session === null) {
      throw new TRPCError({ code: "UNAUTHORIZED" });
    }
    // If authenticated, pass the session down the context (`ctx`)
    // to be used inside the actual procedure.
    return next({
      ctx: {
        ...ctx,
        session,
      },
    });
  });

Key Concepts Explained:

  • Context (createTRPCContext): The context is an object that is available in all of your tRPC procedures. It's the perfect place to put things like database connections, user session information, or other request-specific data. Here, we're initializing it with the request headers.

  • Initialization (initTRPC.create): We create a single tRPC instance for our app. We configure it to use superjson as its data transformer and provide a custom errorFormatter. This formatter is particularly useful as it will nicely structure any Zod validation errors, making them easy to handle on the client.

  • Router and Caller (createTRPCRouter, createCallerFactory): These are the main building blocks for our API. The router organizes our procedures, and the callerFactory allows us to invoke them from our server-side code.

  • Middleware: Middleware are functions that run before (or around) your procedures. Our timingMiddleware is a simple example that logs the duration of each API call. This is a powerful pattern for handling cross-cutting concerns like logging, authentication, and caching.

  • Procedures (publicProcedure, protectedProcedure): A procedure is the equivalent of a single API endpoint. We create reusable "base" procedures here. publicProcedure can be called by anyone. protectedProcedure adds an authentication check, throwing a TRPCError if the user is not logged in. This makes securing our API incredibly declarative and simple.

Step 4: Defining Your First Router (src/server/routers/index.ts)

With the core setup in place, we can now define our first API endpoint. We'll create a main appRouter that will eventually hold all other routers in our application.

Create a new file at src/server/routers/index.ts:

import {
  createCallerFactory,
  createTRPCRouter,
  publicProcedure,
} from "@/server/trpc";

import z from "zod";

export const appRouter = createTRPCRouter({
  hello: publicProcedure
    .input(z.string())
    .query(async ({ input }) => {
      return `Hello ${input}`;
    }),
});

// Export the type of your router. This is what provides the magic of
// end-to-end type safety for the client.
export type AppRouter = typeof appRouter;

// Create a server-side caller for our appRouter
export const createCaller = createCallerFactory(appRouter);

Here, we've defined a single procedure named hello inside our appRouter:

  • It's built upon our publicProcedure, so it's publicly accessible.

  • .input(z.string()): This tells tRPC to validate the incoming data using Zod. It expects a single string as input. If anything else is provided, it will throw a validation error automatically.

  • .query(...): This defines the procedure as a query (for fetching data). The alternative is a .mutation() (for creating, updating, or deleting data).

  • async ({ input }) => { ... }: This is the resolver function—the actual logic that runs when the procedure is called. It receives the validated input and returns a string.

The most important line here is export type AppRouter = typeof appRouter;. This exports the type definition of our entire API, which is the key to achieving full type safety on the client.

Step 5: Creating the Server-Side Caller Instance (src/server/server.ts)

To call our procedures from Next.js Server Components, we need to create a specific caller instance that knows how to generate the context for each request on the server.

Create the file src/server/server.ts:

import { createCaller } from "@/server/routers";
import { createTRPCContext } from "@/server/trpc";
import { headers } from "next/headers";

const createContext = async () => {
  // `headers()` is a Next.js function that provides the request headers.
  const heads = new Headers(headers());
  heads.set("x-trpc-source", "rsc");

  return createTRPCContext({
    headers: heads,
  });
};

export const caller = createCaller(await createContext());

This file creates a pre-configured caller that can be imported directly into any Server Component. It uses the headers() function from next/headers to construct the request context dynamically.

Step 6: Calling the tRPC Procedure from a Server Component

Now for the payoff! Let's call our new hello procedure from our homepage.

Navigate to src/app/page.tsx, clear out the boilerplate code, and replace it with this:

import { caller } from "@/server/server";

export default async function Home() {
  const helloWorld = await caller.hello("world");
  
  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <div className="text-2xl">{helloWorld}</div>
    </main>
  );
}

Notice two key things:

  1. The Home component is now an async function. This is necessary to use await for fetching data directly within a Server Component.

  2. We call await caller.hello("world") just like a regular asynchronous function. If you're using a code editor with TypeScript support, you'll get autocomplete for .hello and a type error if you try to pass anything other than a string as an argument.

Congratulations! 🎉 You have successfully set up a fully type-safe tRPC procedure and consumed it from a Next.js Server Component.

├── src
│   ├── app
│   │   ├── layout.tsx
│   │   └── page.tsx
│   └── server
│       ├── routers
│       │   └── index.ts
│       ├── server.ts
│       └── trpc.ts

Part 2: Bringing Type Safety to the Frontend with Client-Side tRPC

While fetching data in Server Components is incredibly powerful for initial page loads, modern applications thrive on client-side interactivity. This is where the true magic of tRPC shines—it provides the exact same type-safe, autocompleted developer experience in your client components.

In this part, we'll configure the client-side pieces, create a React provider, and transform our homepage into a client component that fetches data using tRPC hooks.

Step 7: Exposing the tRPC Router via an API Route

First, we need to create an actual HTTP endpoint that our client can call. tRPC provides adapters to easily expose your router through various server environments. We'll use the Next.js App Router's Route Handler.

Create a new file at src/app/api/trpc/[trpc]/route.ts:

import { type NextRequest } from "next/server";

import { fetchRequestHandler } from "@trpc/server/adapters/fetch";

import { appRouter } from "@/server/routers";
import { createTRPCContext } from "@/server/trpc";

/**
 * Creates context for an incoming request.
 * @param req - The incoming NextRequest object.
 * @returns The tRPC context.
 */
const createContext = async (req: NextRequest) => {
  return createTRPCContext({
    headers: req.headers,
  });
};

/**
 * The main handler for incoming tRPC requests.
 * It uses the fetchRequestHandler to adapt our tRPC router to the Web Fetch API.
 */
const handler = (req: NextRequest) =>
  fetchRequestHandler({
    endpoint: "/api/trpc",
    req,
    router: appRouter,
    createContext: () => createContext(req),
    onError: ({ path, error }) => {
      console.error(`tRPC failed on ${path ?? "<no-path>"}: ${error.message}`);
    },
  });

// Expose the handler for both GET and POST requests
export { handler as GET, handler as POST };

This file acts as the server-side gateway for all client requests. The [trpc] folder is a dynamic route segment that will catch all requests under /api/trpc/, such as /api/trpc/hello. The fetchRequestHandler from tRPC handles the rest, correctly routing incoming requests to the corresponding procedure in our appRouter.

Step 8: Configuring the tRPC Client

Now, let's set up the client itself. This involves defining how the client connects to the server and handles data serialization. tRPC's React integration is built on top of the excellent TanStack Query, giving us caching, refetching, and complex state management out of the box.

First, create a helper to determine our API's base URL, which can differ between client-side, server-side, and deployment environments.

export const getBaseUrl = () => {
  // Client-side
  if (typeof window !== "undefined") {
    return window.location.origin;
  }
  // Vercel deployment
  if (process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL !== undefined) {
    return `https://${process.env.NEXT_PUBLIC_VERCEL_PROJECT_PRODUCTION_URL}`;
  }
  // Local development or other server environments
  if (process.env.NEXT_PUBLIC_BASE_URL !== undefined) {
    return process.env.NEXT_PUBLIC_BASE_URL;
  }
  return `http://localhost:${process.env.PORT ?? 3000}`;
};

Next, we'll install the essential packages for our tRPC setup on client side.

pnpm add @tanstack/react-query @trpc/client @trpc/react-query

Next, let's create the core client and React Query configuration.

import {
  defaultShouldDehydrateQuery,
  QueryClient,
} from "@tanstack/react-query";
import { loggerLink, httpBatchStreamLink } from "@trpc/client";
import SuperJSON from "superjson";

import { getBaseUrl } from "@/lib/getBaseUrl";
import { type AppRouter } from "./routers";

/**
 * Creates and configures a TanStack QueryClient.
 */
export const createQueryClient = (): QueryClient =>
  new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 30 * 1000,
      },
      // Configuration for data serialization between server and client
      dehydrate: {
        serializeData: SuperJSON.serialize,
        shouldDehydrateQuery: (query): boolean =>
          defaultShouldDehydrateQuery(query) ||
          query.state.status === "pending",
      },
      hydrate: {
        deserializeData: SuperJSON.deserialize,
      },
    },
  });

/**
 * A set of tRPC links that define the client's request flow.
 */
export const links = [
  // Logger link for debugging in development
  loggerLink({
    enabled: (op) =>
      process.env.NODE_ENV === "development" ||
      (op.direction === "down" && op.result instanceof Error),
  }),
  // The main terminating link that sends HTTP requests to our API
  httpBatchStreamLink({
    transformer: SuperJSON, // Use superjson for data serialization
    url: `${getBaseUrl()}/api/trpc`,
    headers: () => {
      const headers = new Headers();
      headers.set("x-trpc-source", "nextjs-react");
      return headers;
    },
  }),
];

Key Concepts Explained:

  • createQueryClient: Sets up TanStack Query, including default options and, crucially, configures it to use SuperJSON for hydration and dehydration. This ensures data types are preserved when passing data from the server to the client.

  • links: This is tRPC's client-side middleware system.

    • loggerLink: A helpful utility that logs tRPC operations to the console during development.

    • httpBatchStreamLink: This is the workhorse. It handles sending requests to our /api/trpc endpoint. "Batching" is a performance optimization where multiple procedure calls made in a short window are bundled into a single HTTP request.

Step 9: Creating the React Provider

To make the tRPC client available throughout our React component tree, we'll create a provider component.

"use client";

import { useState } from "react";

import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
import { createTRPCReact } from "@trpc/react-query";

import { createQueryClient, links } from ".";
import { type AppRouter } from "@/server/routers";

let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = (): QueryClient => {
  if (typeof window === "undefined") {
    // Server: always create a new query client
    return createQueryClient();
  }
  // Browser: use singleton pattern to ensure the same client is used
  clientQueryClientSingleton ??= createQueryClient();
  return clientQueryClientSingleton;
};

// This is the magic object that will generate our typed hooks
export const api = createTRPCReact<AppRouter>();

/**
 * The main tRPC provider component.
 * It wraps the QueryClientProvider and the tRPC provider.
 */
export const TRPCReactProvider = (props: {
  readonly children: React.ReactNode;
}) => {
  const queryClient = getQueryClient();

  const [trpcClient] = useState(() =>
    api.createClient({
      links,
    })
  );

  return (
    <QueryClientProvider client={queryClient}>
      <api.Provider client={trpcClient} queryClient={queryClient}>
        {props.children}
      </api.Provider>
    </QueryClientProvider>
  );
};

Here, createTRPCReact<AppRouter>() is the key. By providing our AppRouter type, we generate a fully-typed api object. This object will give us hooks like api.hello.useQuery that know the exact input and output types of our hello procedure.

Step 10: Wrapping the Application

To ensure our provider is available everywhere, we wrap the root layout with it.

Modify src/app/layout.tsx:

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
      >
        <TRPCReactProvider>{children}</TRPCReactProvider>
      </body>
    </html>
  );
}

Step 11: Calling the Procedure from a Client Component

Finally, let's refactor our Home component to fetch data on the client.

Change src/app/page.tsx to the following:

"use client";

import { api } from "../server/react";

export default function Home() {
  const { data: helloWorld, isLoading } = api.hello.useQuery("world");

  if (isLoading) {
    return (
      <main className="flex min-h-screen flex-col items-center justify-center p-24">
        <div className="text-2xl">Loading...</div>
      </main>
    );
  }

  return (
    <main className="flex min-h-screen flex-col items-center justify-center p-24">
      <div className="text-2xl">{helloWorld}</div>
    </main>
  );
}

The transformation is complete!

  • "use client";: We've designated this as a Client Component.

  • api.hello.useQuery("world"): We're now using a React hook. This hook is automatically generated, fully typed, and powered by TanStack Query. It returns a state object containing our data, a boolean isLoading, error information, and more.

You now have a complete, end-to-end type-safe setup for both server and client data fetching. The developer experience is seamless—whether you're calling a procedure on the server with caller or on the client with api.useQuery, you get the same autocompletion and type-checking, all derived from a single source of truth: your appRouter.

├── src
│   ├── app
│   │   ├── api
│   │   │   └── trpc
│   │   │       └── [trpc]
│   │   │           └── route.ts
│   │   ├── layout.tsx
│   │   └── page.tsx
│   ├── lib
│   │   └── getBaseUrl.ts
│   └── server
│       ├── index.ts
│       ├── react.tsx
│       ├── routers
│       │   └── index.ts
│       ├── server.ts
│       └── trpc.ts

Part 3: Optimizing for React Server Components

Earlier we called the methods on server components by directly calling the methods from the caller. This does not gives us benefits of React Server Components