DEV Community

Atlas Whoff
Atlas Whoff

Posted on

End-to-End Type Safety in Next.js: tRPC, Zod, and Keeping Frontend and Backend in Sync

The Full-Stack TypeScript Type Problem

You define a User type in your backend. You define it again in your frontend. They drift apart. A backend field becomes optional and nobody updates the frontend.

End-to-end type safety solves this. Here are the three main approaches.

Approach 1: tRPC (Best for Next.js Monorepo)

tRPC lets you call server functions from the client with full type inference -- no manual type duplication, no code generation.

npm install @trpc/server @trpc/client @trpc/react-query @tanstack/react-query zod
Enter fullscreen mode Exit fullscreen mode
// server/routers/user.ts
import { router, publicProcedure, protectedProcedure } from '../trpc'
import { z } from 'zod'

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

  updateProfile: protectedProcedure
    .input(z.object({ name: z.string().min(1).max(100) }))
    .mutation(async ({ ctx, input }) => {
      return ctx.db.user.update({
        where: { id: ctx.session.user.id },
        data: { name: input.name }
      })
    }),
})

// Client -- fully typed, no manual type import needed
const { data: profile } = trpc.user.getProfile.useQuery()
// profile is typed as Prisma.User | null automatically

const mutation = trpc.user.updateProfile.useMutation()
mutation.mutate({ name: 'Atlas' }) // TypeScript enforces the input shape
Enter fullscreen mode Exit fullscreen mode

Approach 2: Zod Schemas as Source of Truth

// shared/schemas/user.ts (shared between frontend and backend)
import { z } from 'zod'

export const UserSchema = z.object({
  id: z.string().cuid(),
  email: z.string().email(),
  name: z.string().nullable(),
  plan: z.enum(['free', 'pro', 'enterprise']),
  createdAt: z.string().datetime(),
})

export type User = z.infer<typeof UserSchema>

// Backend: validate DB output
const rawUser = await db.user.findUnique({ where: { id } })
const user = UserSchema.parse(rawUser) // Throws if shape is wrong

// Frontend: validate API response
const res = await fetch('/api/user/me')
const data = await res.json()
const user = UserSchema.parse(data) // Same validation, same types
Enter fullscreen mode Exit fullscreen mode

Approach 3: OpenAPI + Generated Types

npm install @asteasolutions/zod-to-openapi openapi-typescript
Enter fullscreen mode Exit fullscreen mode
// Generate OpenAPI spec from Zod schemas
import { OpenAPIRegistry, OpenApiGeneratorV3 } from '@asteasolutions/zod-to-openapi'

const registry = new OpenAPIRegistry()
registry.register('User', UserSchema)

const generator = new OpenApiGeneratorV3(registry.definitions)
const spec = generator.generateDocument({ openapi: '3.0.0', info: { title: 'API', version: '1.0.0' } })

// Then generate TypeScript types from the spec:
// npx openapi-typescript openapi.json -o src/api-types.ts
Enter fullscreen mode Exit fullscreen mode

Keeping Types in Sync: The Rules

1. Single source of truth -- define each type ONCE
   Good: Zod schema in shared/ folder
   Bad: TypeScript interface in frontend, Prisma model in backend, OpenAPI schema in docs

2. Derive, don't duplicate
   type User = z.infer<typeof UserSchema>  -- derives from schema
   type CreateUser = Omit<User, 'id' | 'createdAt'>  -- derives from User

3. Validate at boundaries
   Validate when data enters your system (API input, DB output, external API response)
   Trust internal data passed between functions

4. Never cast with 'as'
   as User is a lie. If you need to cast, your types are wrong.
Enter fullscreen mode Exit fullscreen mode

This Is Configured in the AI SaaS Starter Kit

Shared Zod schemas, validated API routes, and typed database queries out of the box.

$99 one-time at whoffagents.com

Top comments (0)