Next.js with tRPC — End-to-End Type-Safe APIs in 2026

Sanjeev SharmaSanjeev Sharma
7 min read

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 zod

Server-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 appRouter

Mounting 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 appRouter from the route handler file — the router type must only be imported, not the implementation, on the client side
  • Not wrapping the app in both TRPCProvider and QueryClientProvider — they must both be present
  • Using trpc.useUtils() outside a component — it must be called inside a React component or custom hook
  • Throwing raw Error objects instead of TRPCError — use TRPCError with a code for 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.ts for maintainability
  • Use Zod schemas for input validation in every procedure; the client gets type errors if inputs do not match
  • Use protectedProcedure for any procedure that requires authentication instead of checking ctx.userId inside the handler
  • Enable httpBatchLink to 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 AppRouter type is the single source of truth; it is imported (not instantiated) on the client
  • publicProcedure, protectedProcedure, and adminProcedure are middleware chains for access control
  • Zod schemas in .input() validate and infer types simultaneously — invalid input throws a typed error to the client
  • httpBatchLink batches concurrent queries into one network request, reducing latency
  • utils.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
  • TRPCError with codes like UNAUTHORIZED, FORBIDDEN, and NOT_FOUND maps to correct HTTP status codes

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading