Back to blog
Full-Stack Development
Advanced

Build Type-Safe APIs with tRPC, Next.js 14 & Prisma

Discover how to achieve end-to-end type safety across your full-stack application using tRPC, Next.js 14 App Router, and Prisma ORM. This comprehensive guide walks you through architecture, implementation, and production-ready patterns.

August 27, 202522 min read

Introduction

Building APIs that maintain type safety across the entire stack has long been a holy grail for TypeScript developers. Traditional REST APIs require manual synchronization of types between frontend and backend, leading to runtime errors that could have been caught at compile time. GraphQL improves this with schema introspection but adds significant complexity. Enter tRPC — a lightweight, batteries-included framework that enables end-to-end type safety without code generation, schema files, or runtime overhead.

When combined with Next.js 14 App Router and Prisma ORM, tRPC creates a developer experience that feels almost magical: change your database schema, and your frontend types update instantly. No OpenAPI specs to maintain, no GraphQL resolvers to debug, no any types creeping into your React components.

This guide covers everything you need to build production-grade, type-safe APIs with this stack. We'll explore the architectural principles, walk through a complete implementation, examine real-world patterns, and address deployment, security, and performance considerations. By the end, you'll have a blueprint for building APIs that are not only type-safe but also maintainable, scalable, and a joy to work with.

Table of Contents

Core Concepts

What is tRPC?

tRPC (TypeScript Remote Procedure Call) is a framework that lets you write fully type-safe APIs without schemas or code generation. It leverages TypeScript's type inference to share types between server and client automatically. Unlike GraphQL or REST, tRPC doesn't require a separate schema definition language — your TypeScript code is the schema.

Why Next.js 14 App Router?

The App Router introduces React Server Components (RSC), streaming, and nested layouts — primitives that align perfectly with tRPC's philosophy. Server Components can call tRPC procedures directly on the server, eliminating waterfall requests. The createTRPCNext client integrates seamlessly with Next.js caching and hydration.

Prisma as the Type Bridge

Prisma ORM generates TypeScript types from your database schema. When you run prisma generate, you get fully typed models that match your tables exactly. These types flow directly into your tRPC procedures, creating an unbroken type chain from database to UI.

End-to-End Type Safety Explained

End-to-end type safety means a single source of truth for your data shapes. If you rename a column in schema.prisma, TypeScript will error in your React components until you update them. This eliminates an entire class of runtime bugs: mismatched field names, missing required fields, incorrect payload shapes.

Architecture Overview

High-Level Data Flow

The architecture consists of three layers that share types seamlessly:

  1. Database Layer — Prisma schema defines models, migrations manage changes.
  2. API Layer — tRPC routers define procedures (queries, mutations, subscriptions) with input/output validation via Zod.
  3. Client Layer — React components use typed hooks (useQuery, useMutation) with full autocomplete and inference.

Types flow bidirectionally: Prisma models infer tRPC output types; Zod schemas infer tRPC input types; tRPC router inference creates the client-side type contract.

Project Structure

src/├── app/                    # Next.js App Router│   ├── api/trpc/[trpc]/    # tRPC HTTP handler│   ├── _trpc/              # tRPC client provider│   └── (routes)/           # Page routes├── server/│   ├── api/│   │   ├── routers/        # tRPC routers (modular)│   │   ├── trpc.ts         # tRPC initialization│   │   └── context.ts      # Request context (auth, db)│   └── db.ts               # Prisma client singleton├── lib/│   ├── trpc.ts             # Client-side tRPC hooks│   └── utils.ts            # Shared utilities├── prisma/│   └── schema.prisma       # Database schema└── types/                  # Shared type definitions (if needed)

Key Design Principles

  • Colocation — Keep related procedures in feature-based routers.
  • Single source of truth — Zod schemas live in the router; types are inferred, not duplicated.
  • Server-first — Prefer Server Components calling procedures directly; use client hooks only for interactivity.
  • Explicit context — Context carries authenticated user, database connection, and request metadata.

Step-by-Step Guide

1. Initialize the Project

Create a new Next.js 14 project with TypeScript, Tailwind, and App Router:

npx create-next-app@latest type-safe-api --typescript --tailwind --app --src-dir --import-alias "@/*"cd type-safe-api

2. Install Dependencies

# Core dependenciesnpm install @trpc/server @trpc/client @trpc/next @trpc/react-query @tanstack/react-query zod# Prismanpm install -D prismanpm install @prisma/client# Developmentnpm install -D @types/node

3. Setup Prisma

Initialize Prisma and define your schema:

npx prisma init

Edit prisma/schema.prisma:

generator client {  provider = "prisma-client-js"}datasource db {  provider = "postgresql"  url      = env("DATABASE_URL")}model User {  id        String    @id @default(cuid())  email     String    @unique  name      String?  posts     Post[]  createdAt DateTime  @default(now())  updatedAt DateTime  @updatedAt}model Post {  id        String    @id @default(cuid())  title     String  content   String?  published Boolean   @default(false)  authorId  String  author    User      @relation(fields: [authorId], references: [id])  createdAt DateTime  @default(now())  updatedAt DateTime  @updatedAt}

Run migration and generate client:

npx prisma migrate dev --name initnpx prisma generate

4. Create Prisma Singleton

Prevent multiple Prisma Client instances in development:

// src/server/db.tsimport { PrismaClient } from '@prisma/client'declare global {  var prisma: PrismaClient | undefined}export const prisma =  global.prisma ||  new PrismaClient({    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],  })if (process.env.NODE_ENV !== 'production') global.prisma = prisma

5. Initialize tRPC and Context

Create the tRPC backend with typed context:

// src/server/api/trpc.tsimport { initTRPC, TRPCError } from '@trpc/server'import { type CreateNextContextOptions } from '@trpc/server/adapters/next'import { prisma } from '../db'import { getServerSession } from 'next-auth'import { authOptions } from '@/lib/auth'export interface CreateContextOptions {  session: Awaited> | null}export const createInnerTRPCContext = async (opts: CreateContextOptions) => {  return {    session: opts.session,    prisma,  }}export const createTRPCContext = async (opts: CreateNextContextOptions) => {  const session = await getServerSession(opts.req, opts.res, authOptions)  return createInnerTRPCContext({ session })}const t = initTRPC.context().create()export const createTRPCRouter = t.routerexport const publicProcedure = t.procedureexport const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {  if (!ctx.session?.user) {    throw new TRPCError({ code: 'UNAUTHORIZED' })  }  return next({    ctx: {      ...ctx,      session: { ...ctx.session, user: ctx.session.user },    },  })})export const middleware = t.middleware

6. Create Feature Routers

Build modular routers for each domain:

// src/server/api/routers/post.tsimport { z } from 'zod'import { createTRPCRouter, publicProcedure, protectedProcedure } from '../trpc'import { TRPCError } from '@trpc/server'export const postRouter = createTRPCRouter({  getAll: publicProcedure    .input(z.object({ limit: z.number().min(1).max(100).default(10), cursor: z.string().optional() }))    .query(async ({ ctx, input }) => {      const items = await ctx.prisma.post.findMany({        take: input.limit + 1,        where: { published: true },        cursor: input.cursor ? { id: input.cursor } : undefined,        orderBy: { createdAt: 'desc' },        include: { author: true },      })      let nextCursor: typeof input.cursor | undefined = undefined      if (items.length > input.limit) {        const nextItem = items.pop()        nextCursor = nextItem!.id      }      return { items, nextCursor }    }),  getById: publicProcedure    .input(z.object({ id: z.string().cuid() }))    .query(async ({ ctx, input }) => {      const post = await ctx.prisma.post.findUnique({        where: { id: input.id },        include: { author: true },      })      if (!post) throw new TRPCError({ code: 'NOT_FOUND' })      return post    }),  create: protectedProcedure    .input(z.object({ title: z.string().min(1).max(200), content: z.string().optional() }))    .mutation(async ({ ctx, input }) => {      return ctx.prisma.post.create({        data: {          title: input.title,          content: input.content,          authorId: ctx.session.user.id,        },      })    }),  update: protectedProcedure    .input(z.object({ id: z.string().cuid(), title: z.string().min(1).max(200).optional(), content: z.string().optional(), published: z.boolean().optional() }))    .mutation(async ({ ctx, input }) => {      const { id, ...data } = input      const post = await ctx.prisma.post.findUnique({ where: { id } })      if (!post) throw new TRPCError({ code: 'NOT_FOUND' })      if (post.authorId !== ctx.session.user.id) throw new TRPCError({ code: 'FORBIDDEN' })      return ctx.prisma.post.update({ where: { id }, data })    }),  delete: protectedProcedure    .input(z.object({ id: z.string().cuid() }))    .mutation(async ({ ctx, input }) => {      const post = await ctx.prisma.post.findUnique({ where: { id: input.id } })      if (!post) throw new TRPCError({ code: 'NOT_FOUND' })      if (post.authorId !== ctx.session.user.id) throw new TRPCError({ code: 'FORBIDDEN' })      return ctx.prisma.post.delete({ where: { id: input.id } })    }),})export type PostRouter = typeof postRouter

7. Compose Root Router

// src/server/api/root.tsimport { createTRPCRouter } from './trpc'import { postRouter } from './routers/post'import { userRouter } from './routers/user'export const appRouter = createTRPCRouter({  post: postRouter,  user: userRouter,})export type AppRouter = typeof appRouter

8. Create HTTP Handler

// src/app/api/trpc/[trpc]/route.tsimport { fetchRequestHandler } from '@trpc/server/adapters/fetch'import { appRouter } from '@/server/api/root'import { createTRPCContext } from '@/server/api/trpc'export const runtime = 'edge'const handler = (req: Request) =>  fetchRequestHandler({    endpoint: '/api/trpc',    req,    router: appRouter,    createContext: () => createTRPCContext({ req, res: new Response() }),    onError: ({ error, path }) => {      console.error(`tRPC error on ${path}:`, error)    },  })export { handler as GET, handler as POST }

9. Setup Client Provider

// src/app/_trpc/Providers.tsx'use client'import { createTRPCReact } from '@trpc/react-query'import { QueryClient, QueryClientProvider } from '@tanstack/react-query'import { httpBatchLink } from '@trpc/client'import { type AppRouter } from '@/server/api/root'import { useState } from 'react'import SuperJSON from 'superjson'export const trpc = createTRPCReact()function getBaseUrl() {  if (typeof window !== 'undefined') return ''  return `http://localhost:${process.env.PORT ?? 3000}`}export function TRPCProvider({ children }: { children: React.ReactNode }) {  const [queryClient] = useState(() => new QueryClient({    defaultOptions: { queries: { staleTime: 60 * 1000 } },  }))  const [trpcClient] = useState(() =>    trpc.createClient({      links: [        httpBatchLink({          url: `${getBaseUrl()}/api/trpc`,          transformer: SuperJSON,        }),      ],    })  )  return (          {children}      )}

10. Wrap Root Layout

// src/app/layout.tsximport { TRPCProvider } from './_trpc/Providers'import './globals.css'export default function RootLayout({  children,}: {  children: React.ReactNode}) {  return (                  {children}            )}

11. Use in Server Components

// src/app/posts/page.tsximport { createTRPCProxyClient, httpBatchLink } from '@trpc/client'import { appRouter } from '@/server/api/root'import { createInnerTRPCContext } from '@/server/api/trpc'import { prisma } from '@/server/db'import SuperJSON from 'superjson'import { PostList } from '@/components/PostList'export default async function PostsPage() {  const ctx = await createInnerTRPCContext({ session: null })  const caller = createTRPCProxyClient({    links: [      httpBatchLink({        url: 'http://localhost:3000/api/trpc',        transformer: SuperJSON,        async fetch(url, options) {          return ctx.prisma.$transaction(async (tx) => {            // This is a simplified example; in practice use createCallerFactory            return new Response()          })        },      }),    ],  })  // Better: use createCallerFactory for server-side calls  // See production examples below  return }

12. Use in Client Components

// src/components/PostList.tsx'use client'import { useState } from 'react'import { trpc } from '@/app/_trpc/Providers'import { PostForm } from './PostForm'export function PostList() {  const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = trpc.post.getAll.useInfiniteQuery(    { limit: 10 },    { getNextPageParam: (lastPage) => lastPage.nextCursor }  )  const mutation = trpc.post.create.useMutation({    onSuccess: () => {      trpc.post.getAll.invalidate()    },  })  if (isLoading) return 
Loading...
return (
{data?.pages.flatMap((page) => page.items).map((post) => (

{post.title}

{post.content ?? 'No content'}

By {post.author?.name ?? 'Unknown'}

))}
{hasNextPage && ( )}
)}

Real-World Examples

Authentication Integration

Integrate NextAuth.js with tRPC context for protected procedures:

// src/lib/auth.tsimport { NextAuthOptions } from 'next-auth'import { PrismaAdapter } from '@auth/prisma-adapter'import GitHubProvider from 'next-auth/providers/github'import { prisma } from '@/server/db'export const authOptions: NextAuthOptions = {  adapter: PrismaAdapter(prisma),  providers: [    GitHubProvider({      clientId: process.env.GITHUB_ID!,      clientSecret: process.env.GITHUB_SECRET!,    }),  ],  callbacks: {    session({ session, user }) {      session.user.id = user.id      return session    },  },}

File Uploads with tRPC

Handle multipart/form-data using z.instanceof(File) or upload to S3 first:

// src/server/api/routers/upload.tsimport { createTRPCRouter, protectedProcedure } from '../trpc'import { z } from 'zod'import { UTApi } from 'uploadthing/server'const utapi = new UTApi()export const uploadRouter = createTRPCRouter({  uploadImage: protectedProcedure    .input(z.object({ file: z.instanceof(File), folder: z.string().optional() }))    .mutation(async ({ input }) => {      const { file, folder = 'posts' } = input      const response = await utapi.uploadFiles([file], { folder })      return { url: response[0].data?.url }    }),})export type UploadRouter = typeof uploadRouter

Realtime with Subscriptions

Use WebSocket subscriptions for live updates (requires WebSocket server):

// src/server/api/routers/post.ts (add to router)import { observable } from '@trpc/server/observable'import { EventEmitter } from 'events'const ee = new EventEmitter()// In create mutation, after success:// ee.emit('postCreated', newPost)getAll: publicProcedure  .input(z.object({ limit: z.number().min(1).max(100).default(10) }))  .query(async ({ ctx, input }) => { /* ... */ }),onPostCreated: publicProcedure.subscription(() => {  return observable((emit) => {    const onPostCreated = (post: any) => emit.next(post)    ee.on('postCreated', onPostCreated)    return () => ee.off('postCreated', onPostCreated)  })}),

Batch Operations

Use tRPC's built-in batching for multiple mutations:

// Client componentconst batch = trpc.useMutation([  trpc.post.create.mutationOptions({ /* ... */ }),  trpc.post.update.mutationOptions({ /* ... */ }),])// Or use batch link for queriesimport { httpBatchLink } from '@trpc/client'// Batches multiple queries into single HTTP request automatically

Production Code Examples

Caller Factory for Server Components

The recommended pattern for Server Components:

// src/server/api/trpc.ts (add)import { createCallerFactory } from './trpc'export const createCaller = createCallerFactory(appRouter)// Usage in Server Component// src/app/posts/page.tsximport { createCaller } from '@/server/api/trpc'import { createInnerTRPCContext } from '@/server/api/trpc'export default async function PostsPage() {  const ctx = await createInnerTRPCContext({ session: null })  const caller = createCaller(ctx)  const posts = await caller.post.getAll({ limit: 10 })  return }

Middleware for Logging and Rate Limiting

// src/server/api/trpc.ts (add middleware)export const loggingMiddleware = t.middleware(async ({ next, path, type, ctx }) => {  const start = Date.now()  const result = await next()  const duration = Date.now() - start  console.log(`[tRPC] ${type} ${path} - ${duration}ms`)  return result})export const rateLimitMiddleware = t.middleware(async ({ next, ctx }) => {  // Implement with Upstash Redis or similar  const identifier = ctx.session?.user?.id ?? ctx.req.headers.get('x-forwarded-for') ?? 'anonymous'  // Check rate limit...  return next()})// Apply to proceduresconst protectedProcedure = t.procedure  .use(loggingMiddleware)  .use(rateLimitMiddleware)  .use(async ({ ctx, next }) => {    if (!ctx.session?.user) throw new TRPCError({ code: 'UNAUTHORIZED' })    return next({ ctx: { ...ctx, session: { ...ctx.session, user: ctx.session.user } } })  })

Error Handling and Custom Errors

// src/server/api/trpc.ts (add)export const handleError = ({ error }: { error: TRPCError }) => {  if (error.code === 'INTERNAL_SERVER_ERROR') {    // Log to Sentry, Datadog, etc.    console.error('Unhandled tRPC error:', error)  }  return error}// Custom error codesexport const throwNotFound = (resource: string) =>  new TRPCError({ code: 'NOT_FOUND', message: `${resource} not found` })export const throwForbidden = () =>  new TRPCError({ code: 'FORBIDDEN', message: 'You do not have permission' })// Usage in routercreate: protectedProcedure  .input(z.object({ title: z.string() }))  .mutation(async ({ ctx, input }) => {    const user = await ctx.prisma.user.findUnique({ where: { id: ctx.session.user.id } })    if (!user) throw throwNotFound('User')    if (user.role !== 'ADMIN') throw throwForbidden()    // ...  })

Input Validation with Zod Refinements

// src/server/api/routers/user.tsimport { createTRPCRouter, protectedProcedure } from '../trpc'import { z } from 'zod'export const userRouter = createTRPCRouter({  updateProfile: protectedProcedure    .input(      z.object({        name: z.string().min(2).max(50).optional(),        email: z.string().email().optional(),        bio: z.string().max(500).optional(),      })      .refine((data) => Object.keys(data).length > 0, {        message: 'At least one field must be provided',      })    )    .mutation(async ({ ctx, input }) => {      return ctx.prisma.user.update({        where: { id: ctx.session.user.id },        data: input,      })    }),})export type UserRouter = typeof userRouter

Prisma Transactions in Mutations

// src/server/api/routers/post.tscreateWithTags: protectedProcedure  .input(z.object({ title: z.string(), content: z.string().optional(), tagIds: z.array(z.string().cuid()) }))  .mutation(async ({ ctx, input }) => {    const { tagIds, ...data } = input    return ctx.prisma.$transaction(async (tx) => {      const post = await tx.post.create({        data: { ...data, authorId: ctx.session.user.id },      })      if (tagIds.length > 0) {        await tx.postTag.createMany({          data: tagIds.map((tagId) => ({ postId: post.id, tagId })),          skipDuplicates: true,        })      }      return post    })  })

Comparison Table

AspecttRPCREST (OpenAPI)GraphQL
Type SafetyEnd-to-end inference, zero configRequires code generation (openapi-typescript)Schema-first, requires codegen
Bundle Size~15KB (client)Varies (fetch + types)~40KB+ (Apollo/Urql)
Learning CurveLow (TypeScript only)Low (standard HTTP)High (schema, resolvers, fragments)
CachingReact Query / TanStack Query built-inHTTP caching, SWR/React QueryNormalized cache (Apollo)
Over-fetchingNone (procedure per shape)Common (fixed endpoints)Solved by design
Under-fetchingSolved by nested proceduresCommon (multiple requests)Solved by design
Real-timeSubscriptions (WebSocket)WebSockets / SSE manuallySubscriptions built-in
Server-First (RSC)Native caller patternFetch in Server ComponentsRequires client for fragments
Ecosystem MaturityGrowing fast, Next.js nativeUniversal, mature toolingMature, large ecosystem
Best ForTypeScript full-stack, Next.js appsPublic APIs, microservices, non-TS clientsComplex graphs, multiple clients, federation

Best Practices

Router Organization

  • Split routers by domain (postRouter, userRouter, adminRouter).
  • Compose into a single appRouter at the root.
  • Export router types for client inference.

Procedure Naming

  • Use verbs: getAll, getById, create, update, delete.
  • Suffix with Infinite for paginated queries: getAllInfinite.
  • Group related procedures under namespaces: post.create, post.update.

Input Validation

  • Always validate with Zod — never trust client input.
  • Use z.cuid() or z.uuid() for IDs.
  • Define reusable schemas in a shared schemas.ts if used across routers.

Output Shaping

  • Return Prisma models directly when shape matches; use select to limit fields.
  • Avoid circular references by excluding relations not needed.
  • Use transformers (SuperJSON) for dates, sets, maps, BigInt.

Context Design

  • Keep context minimal: session, prisma, request headers.
  • Don't put business logic in context; use middleware or helpers.
  • Type context strictly — avoid any.

Client Usage

  • Prefer Server Components with createCaller for initial data.
  • Use useQuery / useInfiniteQuery for client-side interactivity.
  • Invalidate queries with utils.post.getAll.invalidate() after mutations.
  • Use useMutation with onSuccess for optimistic updates.

Common Mistakes

1. Leaking Server-Only Code to Client

Importing prisma or Node.js modules in client components causes build errors. Keep server code in src/server/ and use 'use client' only where needed.

2. Over-fetching in Procedures

Returning full relations (e.g., include: { author: { include: { posts: true } } }) bloats payloads. Use select to shape output precisely.

3. Missing Error Boundaries

tRPC errors propagate as exceptions. Wrap client components in error boundaries or use useQuery error state for graceful degradation.

4. Not Using SuperJSON

Without SuperJSON, Dates become strings, Sets/Map become arrays, BigInt loses precision. Always configure transformer on both client and server.

5. Ignoring Batching

Multiple simultaneous queries should use httpBatchLink (default in createTRPCReact). Avoid manual Promise.all for tRPC calls.

6. Tight Coupling to Prisma Models

Returning Prisma models directly couples API to DB schema. Consider DTOs or select for stable contracts.

7. No Authentication Checks on Mutations

Always use protectedProcedure for mutations. Verify ownership (e.g., post.authorId === ctx.session.user.id) before write operations.

8. Forgetting to Invalidate Queries

After mutations, call utils.xxx.invalidate() or use onSuccess to keep UI in sync. Stale data is a common UX bug.

Performance Tips

Prisma Query Optimization

  • Use select instead of include when possible.
  • Add database indexes for filtered/sorted fields (@@index([published, createdAt])).
  • Use prisma.$queryRaw for complex aggregations.
  • Enable query logging in development to spot N+1 issues.

Caching Strategies

  • Set staleTime on React Query (e.g., 60s for lists, 5min for details).
  • Use Next.js unstable_cache in Server Components for expensive computations.
  • Implement HTTP caching headers on tRPC responses for public queries.
  • Consider Redis for distributed caching in multi-instance deployments.

Connection Pooling

Configure Prisma connection pool for serverless:

datasource db {  provider = "postgresql"  url      = env("DATABASE_URL") + "?connection_limit=5&pool_timeout=10"}

Batch Requests

  • tRPC batches automatically with httpBatchLink.
  • Group related queries in a single procedure when they're always fetched together.
  • Use trpc.useQueries for parallel independent queries.

Edge Runtime

Deploy tRPC handler to Edge for lower latency:

// src/app/api/trpc/[trpc]/route.tsexport const runtime = 'edge'// Ensure Prisma uses edge-compatible driver (e.g., @prisma/client with data proxy or Turso/libSQL)

Security Considerations

Input Sanitization

  • Zod validates structure; use z.string().trim() and custom refinements for content.
  • Sanitize HTML content with DOMPurify before storing if rendering user input.
  • Limit upload file types and sizes; scan for malware.

Authorization

  • Implement RBAC in middleware or per-procedure checks.
  • Use protectedProcedure as base; add adminProcedure, ownerProcedure.
  • Never rely on client-side checks alone.

Rate Limiting

  • Apply global rate limiting at Edge or API Gateway level.
  • Per-user limits for mutations (e.g., 10 posts/minute).
  • Use Upstash Redis or Cloudflare Workers KV for distributed rate limiting.

CORS and CSRF

  • tRPC uses POST for mutations; ensure SameSite cookies or CSRF tokens.
  • Configure CORS headers in Next.js next.config.js for production domains only.

Secrets Management

  • Store DATABASE_URL, NEXTAUTH_SECRET, provider secrets in environment variables.
  • Never commit .env files; use Vercel/Netlify secret management.
  • Rotate secrets periodically.

Deployment Notes

  • Native Next.js support, Edge Functions, zero-config.
  • Configure DATABASE_URL with connection pooling (PgBouncer via Supabase/Neon).
  • Set NEXTAUTH_SECRET and OAuth credentials.
  • Enable output: 'standalone' in next.config.js for smaller deployments.

Docker

# DockerfileFROM node:20-alpine AS baseWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npx prisma generateRUN npm run buildEXPOSE 3000CMD ["npm", "start"]

Database Migrations

  • Run npx prisma migrate deploy in CI/CD pipeline before deploy.
  • Use prisma migrate diff to preview changes.
  • Backup database before destructive migrations.

Environment Variables

# ProductionDATABASE_URL="postgresql://user:pass@host:5432/db?schema=public&connection_limit=10"NEXTAUTH_SECRET="generated-with-openssl-rand-base64-32"NEXTAUTH_URL="https://yourdomain.com"GITHUB_ID="oauth-app-id"GITHUB_SECRET="oauth-app-secret"UPLOADTHING_SECRET="ut_secret"UPLOADTHING_APP_ID="app_id"

Monitoring and Logging

  • Integrate Sentry for error tracking (tRPC + Next.js SDKs).
  • Log slow queries (>100ms) and errors to Datadog/Logtail.
  • Set up uptime monitoring for /api/trpc endpoint.

Debugging Tips

tRPC DevTools

Install tRPC DevTools browser extension to inspect queries, mutations, and subscriptions in real-time. Shows input, output, timing, and cache state.

Prisma Query Logging

Enable in development:

// src/server/db.tslog: process.env.NODE_ENV === 'development' ? ['query', 'info', 'warn', 'error'] : ['error']

React Query DevTools

Add to layout for cache inspection:

// src/app/_trpc/Providers.tsximport { ReactQueryDevtools } from '@tanstack/react-query-devtools'// ...return (            {children}            )

Common Type Errors

  • Type 'AppRouter' does not satisfy the constraint 'AnyRouter' — Ensure router export matches createTRPCRouter return type.
  • Property 'post' does not exist on type 'AppRouter' — Check router composition in root.ts.
  • ZodError in mutation — Inspect error.cause for validation details.

Network Inspection

tRPC uses JSON-RPC 2.0 over HTTP. Inspect payloads in DevTools Network tab:- Request: { "jsonrpc": "2.0", "method": "post.getAll", "params": { "input": { "limit": 10 } }, "id": 1 }- Response: { "jsonrpc": "2.0", "result": { "data": [...] }, "id": 1 }

FAQ

Q: Can I use tRPC with an existing REST API?

A: Yes. Create a tRPC router that proxies to your REST endpoints using fetch in procedures. This lets you migrate incrementally while gaining type safety for new features.

Q: Does tRPC work with React Native?

A: Absolutely. The @trpc/client and @trpc/react-query packages work in React Native. Use httpBatchLink with your API URL. Many teams share the same AppRouter type between Next.js web and React Native apps.

Q: How do I handle file uploads?

A: Use UploadThing, AWS S3 presigned URLs, or multipart/form-data with a custom body parser. tRPC procedures can accept File instances via Zod's z.instanceof(File) when using a FormData-aware link.

Q: Is tRPC suitable for public APIs consumed by third parties?

A: tRPC is optimized for TypeScript-to-TypeScript communication. For public APIs with diverse consumers (mobile, other languages), consider REST with OpenAPI or GraphQL. You can expose a tRPC router alongside a REST API.

Q: How do I version my tRPC API?

A: tRPC doesn't have built-in versioning. Common approaches: namespace routers (v1.post, v2.post), use headers, or deploy separate endpoints. Since tRPC is typically internal, breaking changes are managed via TypeScript compilation errors.

Q: Can I use tRPC without Next.js?

A: Yes. tRPC works with any Node.js framework (Express, Fastify, Hono) and any frontend (Vite, Remix, Astro, SvelteKit). The @trpc/server/adapters/* packages provide integrations.

Q: How does tRPC compare to GraphQL Codegen?

A: Both provide end-to-end types. GraphQL Codegen generates types from a schema; tRPC infers types from implementation. tRPC has zero build step, smaller bundle, and simpler mental model, but GraphQL offers better tooling for complex federated schemas and non-TypeScript clients.

Q: What about subscriptions and WebSockets?

A: tRPC supports subscriptions via wsLink and createWSClient. Requires a WebSocket server (e.g., ws library, Socket.io, or Pusher). Works with Next.js using a custom server or separate WebSocket service.

Q: How do I test tRPC procedures?

A: Use createCaller with a test context (mock Prisma, session). Call procedures directly in unit tests. For integration tests, use fetchRequestHandler with a test server. Vitest/Jest work well.

Conclusion

Building type-safe APIs with tRPC, Next.js 14, and Prisma represents a paradigm shift in full-stack development. The elimination of schema synchronization, the confidence of compile-time guarantees across the stack, and the seamless integration with React Server Components create a developer experience that's not just productive — it's delightful.

We've covered the complete lifecycle: from project initialization and Prisma schema design, through modular router architecture and caller patterns, to production concerns like authentication, rate limiting, caching, and deployment. The comparison table illustrates why tRPC is increasingly the default choice for TypeScript-first teams building Next.js applications.

The patterns here scale from side projects to enterprise systems. Start with the caller factory for Server Components, adopt Zod for validation, leverage React Query for client state, and extend with middleware as needs grow. The type system becomes your most valuable refactoring tool — rename a field in schema.prisma, and the compiler guides you through every affected component.

Ready to experience end-to-end type safety? Clone the starter template, connect your database, and watch your API types flow into your React components instantly. Share your experience on Twitter or open a discussion on GitHub. The future of full-stack TypeScript is here — and it's fully typed.