Next.js + tRPC Guide — End-to-End Type Safety Best Practices 2026
Advertisement
Introduction
Why This Matters
Most Next.js applications eventually need a typed API contract between the client and server. REST APIs require manual type synchronization; GraphQL requires a code generator. tRPC takes a different approach: your TypeScript types are the contract, inferred automatically from the router you define on the server.
The result is an end-to-end type-safe stack where renaming a server procedure immediately shows a TypeScript error in every client call — no schema files, no code generation, no runtime surprises.
Project Setup
Create a Next.js project and install tRPC with its React Query adapter:
npx create-next-app@latest my-trpc-app --typescript --app
cd my-trpc-app
npm install @trpc/client @trpc/server @trpc/react-query @tanstack/react-query zodBuilding the tRPC Server
Create src/server/trpc.ts — the tRPC initializer:
import { initTRPC } from '@trpc/server'
import { z } from 'zod'
const t = initTRPC.create()
export const router = t.router
export const publicProcedure = t.procedure
export const middleware = t.middlewareDefine your application router in src/server/index.ts:
import { z } from 'zod'
import { publicProcedure, router } from './trpc'
export const appRouter = router({
// Query: read data
getUser: publicProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
const user = await db.user.findById(input.id)
if (!user) throw new TRPCError({ code: 'NOT_FOUND' })
return user
}),
// Mutation: write data
createPost: publicProcedure
.input(
z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
})
)
.mutation(async ({ input }) => {
return db.post.create({ data: input })
}),
})
// Export the type — used by the client for inference
export type AppRouter = typeof appRouterWire up the HTTP handler in app/api/trpc/[trpc]/route.ts:
import { appRouter } from '@/server'
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: () => ({}),
})
export { handler as GET, handler as POST }Configuring tRPC Clients
Create app/_trpc/client.ts for Client Components:
import { type AppRouter } from '@/server'
import { createTRPCReact } from '@trpc/react-query'
export const trpc = createTRPCReact<AppRouter>()Create app/_trpc/serverClient.ts for Server Components:
import { appRouter } from '@/server'
import { createCallerFactory } from '@trpc/server'
const createCaller = createCallerFactory(appRouter)
export const serverClient = createCaller({})Set up the React Query provider in app/providers.tsx:
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { useState } from 'react'
import { trpc } from './_trpc/client'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/trpc`,
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}Using tRPC in Server Components
Server Components can call tRPC procedures directly — no HTTP overhead:
// app/users/[id]/page.tsx
import { serverClient } from '@/app/_trpc/serverClient'
export default async function UserPage({ params }: { params: { id: string } }) {
// Direct server-side call — fully type-safe, no network round-trip
const user = await serverClient.getUser({ id: params.id })
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)
}Using tRPC in Client Components
Client Components use the React Query hooks for reactive data fetching:
'use client'
import { trpc } from '@/app/_trpc/client'
export function CreatePostForm() {
const utils = trpc.useUtils()
const createPost = trpc.createPost.useMutation({
onSuccess: () => {
utils.getPosts.invalidate() // Refetch posts after creation
},
})
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const data = new FormData(e.currentTarget)
await createPost.mutateAsync({
title: data.get('title') as string,
content: data.get('content') as string,
})
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="Title" required />
<textarea name="content" placeholder="Content" required />
<button type="submit" disabled={createPost.isPending}>
{createPost.isPending ? 'Creating...' : 'Create Post'}
</button>
{createPost.error && <p className="error">{createPost.error.message}</p>}
</form>
)
}Common Mistakes
Forgetting to export AppRouter type. The client needs the type export to infer procedure signatures. If you restructure your router, ensure the type export stays at the module root.
Using createCaller on the client. The server caller bypasses HTTP entirely — it should only be used in Server Components and API routes, never in 'use client' files.
Not handling TRPCError on the client. tRPC wraps server errors into structured objects. Check error.data?.code for tRPC-specific codes (NOT_FOUND, UNAUTHORIZED, etc.) rather than comparing HTTP status codes.
Skipping Zod input validation. Every public procedure should validate its input with .input(zodSchema). Without it, malformed requests reach your business logic unchecked.
Best Practices
- Split routers by domain. Use
router({ users: userRouter, posts: postRouter })at the top level and define each sub-router in its own file. - Add authentication context. Pass the session to
createContextand create anauthedProceduremiddleware that checks it before any protected route runs. - Enable request batching.
httpBatchLink(used above) batches concurrent tRPC calls into a single HTTP request, reducing round-trips on page load. - Use
superjsontransformer. Addtransformer: superjsonto handleDate,Map,Set, andBigIntserialization transparently across the wire. - Deploy the production URL conditionally. Read
process.env.NEXT_PUBLIC_APP_URLso the tRPC client sends requests to the correct origin in both development and production.
Key Takeaways
- tRPC infers client types directly from the server router — there is no schema file or code generation step.
- The
AppRoutertype export is the only artifact shared between server and client; everything else is inferred automatically. - Server Components should use
createCallerto call tRPC procedures without an HTTP round-trip. - Client Components use
trpc.<procedure>.useQuery()and.useMutation()backed by React Query — caching, invalidation, and loading states come for free. - Every procedure should validate its input with a Zod schema passed to
.input()to prevent malformed data from reaching business logic. - tRPC supports protected procedures via middleware — create an
authedProcedurethat reads the session from context before executing. httpBatchLinkcollapses concurrent procedure calls into one HTTP request, significantly reducing latency on initial page load.- tRPC error codes (
NOT_FOUND,UNAUTHORIZED,BAD_REQUEST) map to HTTP status codes automatically, making API debugging straightforward.
Advertisement