deep-dive2025-02-01Β·13Β·212/348

Setting Up tRPC for Type-Safe APIs Across Stack

A complete guide to configuring tRPC with Next.js, including procedure definitions, middleware, error handling, and subscriptions for real-time features.

Introduction

tRPC eliminates the gap between frontend and backend by sharing TypeScript types directly. After using tRPC in three production projects, I believe it is the fastest way to build type-safe full-stack applications. The initial setup is straightforward, but configuring error handling, authentication middleware, and subscriptions requires careful planning.

This post walks through a complete tRPC setup with Next.js, covering procedure definitions, context creation, middleware chaining, and client-side integration. I will also address the common issues that arise when scaling beyond simple CRUD operations.

Environment

node --version
# v20.11.0

npm list next trpc @trpc/server @trpc/client
# next@14.0.4
# @trpc/server@11.0.0-rc.228
# @trpc/client@11.0.0-rc.228
# @trpc/next@11.0.0-rc.228
# @trpc/react-query@11.0.0-rc.228

npm list @tanstack/react-query
# @tanstack/react-query@5.17.0

Project structure:

trpc-nextjs/
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ server/
β”‚   β”‚   β”œβ”€β”€ trpc.ts          # tRPC instance
β”‚   β”‚   β”œβ”€β”€ context.ts       # Request context
β”‚   β”‚   β”œβ”€β”€ routers/
β”‚   β”‚   β”‚   β”œβ”€β”€ _app.ts      # Root router
β”‚   β”‚   β”‚   β”œβ”€β”€ user.ts      # User procedures
β”‚   β”‚   β”‚   └── post.ts      # Post procedures
β”‚   β”‚   └── middleware/
β”‚   β”‚       β”œβ”€β”€ auth.ts      # Auth middleware
β”‚   β”‚       └── logger.ts    # Logging middleware
β”‚   β”œβ”€β”€ pages/
β”‚   β”‚   β”œβ”€β”€ _app.tsx
β”‚   β”‚   └── index.tsx
β”‚   └── utils/
β”‚       └── trpc.ts          # Client utilities
β”œβ”€β”€ package.json
└── tsconfig.json

Problem

The initial setup worked for basic procedures, but several issues emerged as the application grew. First, the context object was not properly typed, causing runtime errors when accessing session data. Second, error handling across procedures was inconsistent because each procedure defined its own error responses. Third, the client-side integration with React Query caused unnecessary re-renders due to stale cache configuration.

The most frustrating issue was that the context type error only appeared at runtime, not during compilation:

// This compiled without errors
const createContext = async ({ req }: CreateContextOptions) => {
  const session = await getSession(req);
  return {
    session,  // Type was inferred as Session | null
  };
};

// But accessing session.userId in procedures threw
// "Cannot read property 'userId' of null"
// at runtime

The second issue was that error handling in the tRPC context did not propagate correctly to the client:

// Server-side procedure
const getUser = publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input, ctx }) => {
    const user = await db.user.findUnique({ where: { id: input.id } });
    if (!user) {
      throw new TRPCError({
        code: "NOT_FOUND",
        message: "User not found",
      });
    }
    return user;
  });

// Client received a generic error instead of the custom message

Analysis

The context typing issue occurs because TypeScript's infer behavior with async functions wraps the return type in a Promise. The tRPC context type must match exactly between server and client, and any mismatch causes runtime failures that TypeScript does not catch at compile time.

The error handling issue stems from tRPC's error formatting pipeline. The TRPCError class has a specific format that the client expects, but the default error formatter in Apollo Server or plain Express does not transform errors into the tRPC format automatically.

The React Query re-rendering issue is caused by the default staleTime of 0, which means every component mount triggers a refetch. For read-heavy applications, this defeats the purpose of client-side caching.

Solution

Define a strict context type that separates optional and required fields:

// src/server/context.ts
import { inferAsyncReturnType } from "@trpc/server";
import { CreateNextContextOptions } from "@trpc/server/adapters/next";
import { getSession } from "next-auth/react";
import { db } from "../db";

export async function createContextInner(opts: {
  session: Session | null;
}) {
  return {
    session: opts.session,
    db,
  };
}

export async function createContext({ req, res }: CreateNextContextOptions) {
  const session = await getSession({ req });

  return createContextInner({
    session,
  });
}

export type Context = inferAsyncReturnType;

Create a type-safe tRPC instance with middleware:

// src/server/trpc.ts
import { initTRPC, TRPCError } from "@trpc/server";
import { Context } from "./context";
import superjson from "superjson";

const t = initTRPC.context().create({
  transformer: superjson,
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        zodError:
          error.code === "BAD_REQUEST" && error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
      },
    };
  },
});

const loggerMiddleware = t.middleware(async ({ path, type, next }) => {
  const start = Date.now();
  const result = await next();
  const duration = Date.now() - start;
  console.log(`${type} ${path} - ${duration}ms`);
  return result;
});

const authMiddleware = t.middleware(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to access this resource",
    });
  }

  return next({
    ctx: {
      session: { ...ctx.session, user: ctx.session.user },
    },
  });
});

export const publicProcedure = t.procedure.use(loggerMiddleware);
export const protectedProcedure = t.procedure
  .use(loggerMiddleware)
  .use(authMiddleware);
export const router = t.router;

Define procedures with proper error handling:

// src/server/routers/user.ts
import { router, publicProcedure, protectedProcedure } from "../trpc";
import { z } from "zod";

export const userRouter = router({
  getById: publicProcedure
    .input(z.object({ id: z.string().uuid() }))
    .query(async ({ input, ctx }) => {
      const user = await ctx.db.user.findUnique({
        where: { id: input.id },
        select: {
          id: true,
          name: true,
          email: true,
          createdAt: true,
        },
      });

      if (!user) {
        throw new TRPCError({
          code: "NOT_FOUND",
          message: `User with id ${input.id} not found`,
        });
      }

      return user;
    }),

  updateProfile: protectedProcedure
    .input(
      z.object({
        name: z.string().min(1).max(100),
        bio: z.string().max(500).optional(),
      })
    )
    .mutation(async ({ input, ctx }) => {
      const updated = await ctx.db.user.update({
        where: { id: ctx.session.user.id },
        data: input,
      });

      return updated;
    }),
});

Configure the root router:

// src/server/routers/_app.ts
import { router } from "../trpc";
import { userRouter } from "./user";
import { postRouter } from "./post";

export const appRouter = router({
  user: userRouter,
  post: postRouter,
});

export type AppRouter = typeof appRouter;

Set up the client with proper caching:

// src/utils/trpc.ts
import { createTRPCReact } from "@trpc/react-query";
import type { AppRouter } from "../server/routers/_app";
import superjson from "superjson";
import { httpBatchLink } from "@trpc/client";

export const trpc = createTRPCReact();

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 trpcClient() {
  return trpc.createClient({
    links: [
      httpBatchLink({
        url: `${getBaseUrl()}/api/trpc`,
        transformer: superjson,
        headers() {
          const headers = new Headers();
          headers.set("x-trpc-source", "nextjs-react");
          return headers;
        },
      }),
    ],
  });
}

Configure the Next.js app with tRPC provider:

// src/pages/_app.tsx
import type { AppType } from "next/app";
import { trpc, trpcClient } from "../utils/trpc";

const MyApp: AppType = ({ Component, pageProps }) => {
  const [trpcClientState] = useState(() => trpcClient());

  return (
    
      
        
      
    
  );
};

export default MyApp;

Use tRPC in components with proper typing:

// src/pages/index.tsx
import { trpc } from "../utils/trpc";

export default function Home() {
  const { data: user, isLoading, error } = trpc.user.getById.useQuery({
    id: "123e4567-e89b-12d3-a456-426614174000",
  }, {
    staleTime: 5 * 60 * 1000, // 5 minutes
    retry: 2,
  });

  if (isLoading) return 
Loading...
; if (error) return
Error: {error.message}
; return (

{user?.name}

{user?.email}

); }

Lessons Learned

  1. Always use inferAsyncReturnType for the context type to ensure the server and client context types match exactly.

  2. Configure staleTime in React Query to match your data freshness requirements. Default settings cause excessive refetching.

  3. Use superjson as the transformer to support Date, Map, Set, and other non-JSON-native types in your procedures.

  4. Group related procedures into sub-routers to keep the root router clean and make code navigation easier.

  5. The tRPC error formatter can return custom data to the client. Use this to send structured error information that the frontend can display meaningfully.

This blog does not accept any external sponsorships, affiliate marketing, or ad revenue.