tRPC + Next.js: From Zero to Fullstack Hero
Fullstack

tRPC + Next.js: From Zero to Fullstack Hero

Sagar Yenkure
April 12th, 2025

This guide walks you through integrating tRPC — a fully typesafe API framework — with Next.js, building a powerful, schema-free fullstack application.

We'll cover:

  • tRPC setup
  • React Query (TanStack Query) integration
  • Zod for input validation
  • SuperJSON for data serialization
  • Middleware for role-based access control

Why tRPC + Next.js?

  • tRPC allows you to call backend functions from the frontend without writing API schemas or REST/GraphQL endpoints.
  • Next.js provides server-side rendering (SSR), server components, and API routes — all ideal for tRPC.

Together, they enable end-to-end type safety with minimal boilerplate.


Prerequisites

Make sure you have a Next.js project. If not, create one:

npx create-next-app@latest
Click to Copy

Install the required packages:

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod superjson
Click to Copy

Optional: Prisma Zod Integration

If you're using Prisma and want to use Zod types generated from your schema:

npm install zod-prisma-types
Click to Copy

Add this to your Prisma schema:

generator zod { provider = "zod-prisma-types" }
Click to Copy

Then run:

npx prisma generate zod
Click to Copy

The types will be generated in

schema/generated/zod
Click to Copy
.


Step 1: Initialize tRPC

// src/trpc/init.ts import { initTRPC } from "@trpc/server"; import { cache } from "react"; import superjson from "superjson"; export const createTRPCContext = cache(async () => { // Here you can auth to get user session to add auth middleware const user = { name: "sager yenkure", id: "112233", role: "user", }; return user || { name: null, id: null, role: null }; }); type Context = Awaited<ReturnType<typeof createTRPCContext>>; const t = initTRPC.context<Context>().create({ transformer: superjson, }); export const createTRPCRouter = t.router; export const createCallerFactory = t.createCallerFactory; export const publicProcedure = t.procedure; export const middleware = t.middleware;
Click to Copy

This sets up a reusable tRPC instance with a context containing user data — enabling role-based logic later.


Step 2: Define the Router

// src/trpc/router.ts import { z } from "zod"; import { createTRPCRouter, publicProcedure } from "./init"; import { createUser, getAllUsers, getUserById } from "./procedures/users"; export const userRouter = { getAll: publicProcedure.query(() => getAllUsers()), getById: publicProcedure .input(z.object({ id: z.number() })) .query(({ input }) => getUserById(input.id)), createUser: publicProcedure .input(z.object({ name: z.string().min(1) })) .mutation(({ input }) => createUser(input.name)), }; export const authRouter = {}; export const trpcRouter = createTRPCRouter({ users: userRouter, auth: authRouter, }); export type TRPCRouter = typeof trpcRouter;
Click to Copy

Each route is defined with Zod for type-safe inputs and maps directly to backend logic.


Step 3: Setup the Server Caller

// src/trpc/server.ts import "server-only"; import { createHydrationHelpers } from "@trpc/react-query/rsc"; import { cache } from "react"; import { makeQueryClient } from "./query-client"; import { trpcRouter } from "./router"; import { createCallerFactory, createTRPCContext } from "./init"; export const getQueryClient = cache(makeQueryClient); const caller = createCallerFactory(trpcRouter)(createTRPCContext); export const { trpc, HydrateClient } = createHydrationHelpers<typeof trpcRouter>( caller, getQueryClient );
Click to Copy

This enables React Server Components (RSC) support for tRPC.


Step 4: Setup Query Client

// src/trpc/query-client.ts import { defaultShouldDehydrateQuery, QueryClient, } from "@tanstack/react-query"; export function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { staleTime: 30 * 1000, }, dehydrate: { shouldDehydrateQuery: (query) => defaultShouldDehydrateQuery(query) || query.state.status === "pending", }, }, }); }
Click to Copy

Creates a reusable query client for SSR and hydration.


Step 5: Setup Client Provider

// src/trpc/client.tsx "use client"; import { QueryClientProvider } from "@tanstack/react-query"; import SuperJSON from "superjson"; import { useState } from "react"; import { makeQueryClient } from "./query-client"; import { TRPCRouter } from "./router"; import { createTRPCReact } from "@trpc/react-query"; import { httpBatchLink } from "@trpc/client"; export const trpc = createTRPCReact<TRPCRouter>(); let clientQueryClientSingleton: QueryClient; function getQueryClient() { if (typeof window === "undefined") return makeQueryClient(); return (clientQueryClientSingleton ??= makeQueryClient()); } function getUrl() { const base = typeof window !== "undefined" ? "" : process.env.NEXT_PUBLIC_BASE_URL || "http://localhost:3000"; return `${base}/api/trpc`; } export function TRPCProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient(); const [trpcClient] = useState(() => trpc.createClient({ links: [ httpBatchLink({ transformer: SuperJSON, url: getUrl(), }), ], }) ); return ( <trpc.Provider client={trpcClient} queryClient={queryClient}> <QueryClientProvider client={queryClient}> {children} </QueryClientProvider> </trpc.Provider> ); }
Click to Copy

Wraps your app with both tRPC and React Query providers.


Step 6: Wrap Your Root Layout

// app/layout.tsx import { TRPCProvider } from "@/trpc/client"; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <TRPCProvider>{children}</TRPCProvider> </body> </html> ); }
Click to Copy

Makes tRPC and React Query globally available.


Step 7: Use tRPC in Server Components

// app/page.tsx import { trpc } from "@/trpc/server"; export default async function Home() { const { data, isLoading } = trpc.users.getById.useQuery({ id: 2 }); const { data: allUsers, isLoading: allUserLoading } = trpc.users.getAll.useQuery(); return ( <main> {isLoading ? <p>Loading user...</p> : <div>{data?.name}</div>} <ol> {allUserLoading ? <p>Loading all users...</p> : allUsers?.map((user, idx) => <li key={idx}>Welcome {user.name}</li>) } </ol> </main> ); }
Click to Copy

SSR-safe queries with full type safety.


Step 8: Use tRPC in Client Components

"use client"; import { useState } from "react"; import { trpc } from "@/trpc/client"; export default function Home() { const [newUserName, setNewUserName] = useState(""); const utils = trpc.useUtils(); const { data, isLoading } = trpc.users.getById.useQuery({ id: 2 }); const { data: allUsers, isLoading: allUserLoading } = trpc.users.getAll.useQuery(); const { mutate, isPending } = trpc.users.createUser.useMutation({ onSuccess: () => utils.users.getAll.invalidate(), onError: (err) => console.error(err.message), }); const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (newUserName.trim()) mutate({ name: newUserName }); }; return ( <main> <div>{isLoading ? "Loading..." : data?.name}</div> <ol> {allUserLoading ? <p>Loading users...</p> : allUsers?.map((user, idx) => <li key={idx}>Welcome {user.name}</li>) } </ol> <form onSubmit={handleSubmit}> <input value={newUserName} onChange={(e) => setNewUserName(e.target.value)} /> <button type="submit">{isPending ? "Adding..." : "Add User"}</button> </form> </main> ); }
Click to Copy

Client-side mutation with form state, optimistic updates, and cache invalidation.


Step 9: Add Middleware for RBAC (Role-Based Access Control)

// src/trpc/middleware.ts import { middleware } from "./init"; export const userMiddleware = middleware(({ ctx, next }) => { if (!ctx?.role) throw new Error("Not logged in"); return next({ ctx }); }); export const adminMiddleware = middleware(({ ctx, next }) => { if (ctx?.role !== "admin") throw new Error("Not authorized"); return next({ ctx }); });
Click to Copy

Apply middleware in your router:

import { adminMiddleware, userMiddleware } from "./middleware"; import { z } from "zod"; export const userRouter = { getAll: publicProcedure.query(() => getAllUsers()), getById: publicProcedure .input(z.object({ id: z.number() })) .query(({ input }) => getUserById(input.id)), createUser: publicProcedure .use(userMiddleware) .use(adminMiddleware) .input(z.object({ name: z.string().min(1) })) .mutation(({ input }) => createUser(input.name)), };
Click to Copy

Adds layered access control using composable middleware.


Final Thoughts

Using tRPC with Next.js gives you:

  • Full end-to-end type safety
  • Real-time feedback from your backend
  • No need to maintain API schemas
  • Clean integration with React Query and server components

This setup is scalable, developer-friendly, and production-ready.