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.0Project 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.jsonProblem
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 runtimeThe 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 messageAnalysis
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
Always use
inferAsyncReturnTypefor the context type to ensure the server and client context types match exactly.Configure
staleTimein React Query to match your data freshness requirements. Default settings cause excessive refetching.Use
superjsonas the transformer to support Date, Map, Set, and other non-JSON-native types in your procedures.Group related procedures into sub-routers to keep the root router clean and make code navigation easier.
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.