Next.js with tRPC — End-to-End Type-Safe APIs in 2026
Advertisement
Introduction
Why This Matters
REST APIs require you to manually keep frontend types in sync with backend responses. GraphQL needs a schema, a codegen step, and a separate client library. tRPC takes a different approach: your router definition IS the type, shared directly between server and client. If you rename a procedure or change its output type, TypeScript immediately errors on every call site in your frontend code.
This is the fastest way to build a type-safe full-stack application in Next.js when the frontend and backend are in the same repository (monorepo or Next.js full-stack app).
Installation
npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zodServer-Side Router Setup
Create the tRPC initialization and base procedures:
// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { auth } from '@/auth'
import { ZodError } from 'zod'
export type Context = {
userId: string | null
role: 'USER' | 'ADMIN' | null
}
async function createContext(): Promise<Context> {
const session = await auth()
return {
userId: session?.user?.id ?? null,
role: (session?.user as any)?.role ?? null,
}
}
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
zodError: error.cause instanceof ZodError ? error.cause.flatten() : null,
},
}
},
})
export const createTRPCRouter = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
if (!ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return next({ ctx: { ...ctx, userId: ctx.userId } })
})
export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
if (ctx.role !== 'ADMIN') {
throw new TRPCError({ code: 'FORBIDDEN' })
}
return next({ ctx })
})
export { createContext }Defining Routers
// server/routers/posts.ts
import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc'
import { prisma } from '@/lib/prisma'
import { z } from 'zod'
const createPostInput = z.object({
title: z.string().min(1, 'Title is required').max(200),
content: z.string().min(10, 'Content must be at least 10 characters'),
slug: z.string().min(1).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase with hyphens'),
})
export const postsRouter = createTRPCRouter({
list: publicProcedure
.input(z.object({ page: z.number().min(1).default(1), limit: z.number().min(1).max(50).default(10) }))
.query(async ({ input }) => {
const skip = (input.page - 1) * input.limit
const [posts, total] = await Promise.all([
prisma.post.findMany({
where: { published: true },
include: { author: { select: { id: true, name: true } } },
orderBy: { createdAt: 'desc' },
skip,
take: input.limit,
}),
prisma.post.count({ where: { published: true } }),
])
return { posts, total, pages: Math.ceil(total / input.limit) }
}),
bySlug: publicProcedure
.input(z.string().min(1))
.query(async ({ input }) => {
const post = await prisma.post.findUnique({
where: { slug: input },
include: { author: true, tags: true },
})
if (!post) throw new Error('Post not found')
return post
}),
create: protectedProcedure
.input(createPostInput)
.mutation(async ({ input, ctx }) => {
return prisma.post.create({
data: { ...input, authorId: ctx.userId },
include: { author: { select: { name: true } } },
})
}),
update: protectedProcedure
.input(z.object({ id: z.string(), data: createPostInput.partial() }))
.mutation(async ({ input, ctx }) => {
const post = await prisma.post.findUnique({ where: { id: input.id } })
if (!post || post.authorId !== ctx.userId) {
throw new Error('Not authorized to update this post')
}
return prisma.post.update({ where: { id: input.id }, data: input.data })
}),
delete: protectedProcedure
.input(z.string())
.mutation(async ({ input, ctx }) => {
const post = await prisma.post.findUnique({ where: { id: input } })
if (!post || post.authorId !== ctx.userId) {
throw new Error('Not authorized to delete this post')
}
return prisma.post.delete({ where: { id: input } })
}),
})// server/routers/_app.ts
import { createTRPCRouter } from '../trpc'
import { postsRouter } from './posts'
import { usersRouter } from './users'
export const appRouter = createTRPCRouter({
posts: postsRouter,
users: usersRouter,
})
export type AppRouter = typeof appRouterMounting the Route Handler
// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/trpc'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext,
})
export { handler as GET, handler as POST }Client-Side Setup
// lib/trpc/client.ts
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from '@/server/routers/_app'
export const trpc = createTRPCReact<AppRouter>()// app/providers.tsx
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink, loggerLink } from '@trpc/client'
import { useState } from 'react'
import { trpc } from '@/lib/trpc/client'
export function TRPCProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { staleTime: 60 * 1000 } },
}))
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
loggerLink({ enabled: () => process.env.NODE_ENV === 'development' }),
httpBatchLink({ url: '/api/trpc' }),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}Using Queries and Mutations in Components
// app/components/post-list.tsx
'use client'
import { trpc } from '@/lib/trpc/client'
export function PostList() {
const { data, isLoading, error } = trpc.posts.list.useQuery({ page: 1, limit: 10 })
if (isLoading) return <div className="animate-pulse">Loading posts...</div>
if (error) return <p className="text-red-600">Failed to load posts: {error.message}</p>
return (
<ul className="space-y-4">
{data?.posts.map((post) => (
<li key={post.id}>
<h2 className="text-xl font-semibold">{post.title}</h2>
<p className="text-gray-500">By {post.author.name}</p>
</li>
))}
</ul>
)
}// app/components/create-post-form.tsx
'use client'
import { trpc } from '@/lib/trpc/client'
import { useState } from 'react'
export function CreatePostForm() {
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const utils = trpc.useUtils()
const createMutation = trpc.posts.create.useMutation({
onSuccess: () => {
setTitle('')
setContent('')
// Invalidate the post list so it refetches
utils.posts.list.invalidate()
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
createMutation.mutate({
title,
content,
slug: title.toLowerCase().replace(/\s+/g, '-'),
})
}}
className="space-y-4"
>
{createMutation.error && (
<p className="text-red-600">{createMutation.error.message}</p>
)}
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Post title"
className="w-full border p-2 rounded"
/>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Post content"
rows={6}
className="w-full border p-2 rounded"
/>
<button
type="submit"
disabled={createMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{createMutation.isPending ? 'Publishing...' : 'Publish'}
</button>
</form>
)
}Common Mistakes
- Exporting
appRouterfrom the route handler file — the router type must only be imported, not the implementation, on the client side - Not wrapping the app in both
TRPCProviderandQueryClientProvider— they must both be present - Using
trpc.useUtils()outside a component — it must be called inside a React component or custom hook - Throwing raw
Errorobjects instead ofTRPCError— useTRPCErrorwith acodefor proper HTTP status codes - Not calling
utils.posts.list.invalidate()after mutations — query cache stays stale
Best Practices
- Split routers by domain (posts, users, comments) and merge in
_app.tsfor maintainability - Use Zod schemas for input validation in every procedure; the client gets type errors if inputs do not match
- Use
protectedProcedurefor any procedure that requires authentication instead of checkingctx.userIdinside the handler - Enable
httpBatchLinkto batch multiple queries into a single HTTP request - Use
trpc.useUtils()to invalidate or prefetch queries after mutations for optimistic UX
Key Takeaways
- tRPC shares types directly between server and client — no codegen step, no manual type maintenance
- The
AppRoutertype is the single source of truth; it is imported (not instantiated) on the client publicProcedure,protectedProcedure, andadminProcedureare middleware chains for access control- Zod schemas in
.input()validate and infer types simultaneously — invalid input throws a typed error to the client httpBatchLinkbatches concurrent queries into one network request, reducing latencyutils.posts.list.invalidate()after mutations triggers React Query to refetch stale data- tRPC is best for monorepo or full-stack Next.js apps; for public APIs, REST or GraphQL remain better options
TRPCErrorwith codes likeUNAUTHORIZED,FORBIDDEN, andNOT_FOUNDmaps to correct HTTP status codes
Advertisement
Related reading
tRPC in Production — End-to-End Type Safety Without the GraphQL Overhead6 min readAPI Versioning in Node.js and TypeScript — Complete 2026 Guide5 min readBuild an AI Chatbot with Next.js 15 and OpenAI — Full Stack 20266 min readGraphQL with TypeScript — Schema, Resolvers, and Apollo Server 20246 min readREST vs GraphQL vs tRPC — Complete API Comparison 20267 min readNext.js 15 Complete Guide 2026 — App Router, Server Components, and Performance4 min read