DEV Community

Atlas Whoff
Atlas Whoff

Posted on

tRPC with Next.js 14 App Router: End-to-End Type Safety Without the REST Layer

TRPC eliminates the entire API layer between your Next.js frontend and backend. No REST endpoints to design, no OpenAPI specs to maintain, no fetch calls to write — just TypeScript functions you call directly from your components with full type safety.

Why tRPC

Traditional API development:

  1. Define route in app/api/route.ts
  2. Write types for request and response
  3. Write fetch call in the frontend
  4. Keep both sides in sync manually

With tRPC:

  1. Write a typed procedure on the server
  2. Call it from the client — TypeScript validates the call

Any change to the server procedure immediately surfaces as a type error in the client. The API contract is enforced by the compiler.

Setup

npm install @trpc/server @trpc/client @trpc/next @trpc/react-query @tanstack/react-query zod
Enter fullscreen mode Exit fullscreen mode

Server Setup

// server/trpc.ts
import { initTRPC, TRPCError } from '@trpc/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { z } from 'zod'

const t = initTRPC.context<Context>().create()

export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED' })
  }
  return next({ ctx: { ...ctx, user: ctx.session.user } })
})
Enter fullscreen mode Exit fullscreen mode
// server/routers/users.ts
import { router, protectedProcedure } from '../trpc'
import { z } from 'zod'
import { db } from '@/lib/db'

export const usersRouter = router({
  getProfile: protectedProcedure.query(async ({ ctx }) => {
    return db.user.findUnique({ where: { id: ctx.user.id } })
  }),

  updateProfile: protectedProcedure
    .input(z.object({
      name: z.string().min(1).max(100),
      bio: z.string().max(500).optional(),
    }))
    .mutation(async ({ ctx, input }) => {
      return db.user.update({
        where: { id: ctx.user.id },
        data: input,
      })
    }),
})
Enter fullscreen mode Exit fullscreen mode

App Router Integration

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/server/routers/_app'
import { createContext } from '@/server/context'

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext,
  })

export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode

Client Usage

// components/Profile.tsx
'use client'
import { trpc } from '@/lib/trpc'

export function Profile() {
  const { data: profile } = trpc.users.getProfile.useQuery()
  const updateProfile = trpc.users.updateProfile.useMutation({
    onSuccess: () => trpc.useUtils().users.getProfile.invalidate(),
  })

  return (
    <form onSubmit={(e) => {
      e.preventDefault()
      updateProfile.mutate({ name: 'New Name' })
    }}>
      <input defaultValue={profile?.name} />
      <button type='submit'>Save</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

TypeScript will error if you call updateProfile.mutate({ namee: 'typo' }) — the Zod schema is the single source of truth for both validation and types.

Server-Side Calls

Call tRPC procedures directly in Server Components (no HTTP round-trip):

// app/dashboard/page.tsx
import { createCaller } from '@/server/routers/_app'
import { createContext } from '@/server/context'

export default async function Dashboard() {
  const ctx = await createContext()
  const caller = createCaller(ctx)
  const profile = await caller.users.getProfile()

  return <div>{profile.name}</div>
}
Enter fullscreen mode Exit fullscreen mode

Error Handling

tRPC maps error codes to HTTP status codes automatically:

import { TRPCError } from '@trpc/server'

// In a procedure
if (!resource) {
  throw new TRPCError({ code: 'NOT_FOUND', message: 'Resource not found' })
}
if (!hasPermission) {
  throw new TRPCError({ code: 'FORBIDDEN', message: 'Access denied' })
}
Enter fullscreen mode Exit fullscreen mode

On the client, error.data.code gives you the tRPC error code, error.message gives the human-readable message.


The AI SaaS Starter at whoffagents.com ships with tRPC pre-configured alongside NextAuth and Prisma — protected procedures, context setup, and client hooks all wired. $99 one-time.

Top comments (0)