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
- Introduction
- Core Concepts
- Architecture Overview
- Step-by-Step Guide
- Real-World Examples
- Production Code Examples
- Comparison Table
- Best Practices
- Common Mistakes
- Performance Tips
- Security Considerations
- Deployment Notes
- Debugging Tips
- FAQ
- Conclusion
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:
- Database Layer — Prisma schema defines models, migrations manage changes.
- API Layer — tRPC routers define procedures (queries, mutations, subscriptions) with input/output validation via Zod.
- 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-api2. 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/node3. Setup Prisma
Initialize Prisma and define your schema:
npx prisma initEdit 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 generate4. 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 = prisma5. 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 postRouter7. 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 appRouter8. 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 uploadRouterRealtime 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 automaticallyProduction 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 userRouterPrisma 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
| Aspect | tRPC | REST (OpenAPI) | GraphQL |
|---|---|---|---|
| Type Safety | End-to-end inference, zero config | Requires code generation (openapi-typescript) | Schema-first, requires codegen |
| Bundle Size | ~15KB (client) | Varies (fetch + types) | ~40KB+ (Apollo/Urql) |
| Learning Curve | Low (TypeScript only) | Low (standard HTTP) | High (schema, resolvers, fragments) |
| Caching | React Query / TanStack Query built-in | HTTP caching, SWR/React Query | Normalized cache (Apollo) |
| Over-fetching | None (procedure per shape) | Common (fixed endpoints) | Solved by design |
| Under-fetching | Solved by nested procedures | Common (multiple requests) | Solved by design |
| Real-time | Subscriptions (WebSocket) | WebSockets / SSE manually | Subscriptions built-in |
| Server-First (RSC) | Native caller pattern | Fetch in Server Components | Requires client for fragments |
| Ecosystem Maturity | Growing fast, Next.js native | Universal, mature tooling | Mature, large ecosystem |
| Best For | TypeScript full-stack, Next.js apps | Public APIs, microservices, non-TS clients | Complex graphs, multiple clients, federation |
Best Practices
Router Organization
- Split routers by domain (
postRouter,userRouter,adminRouter). - Compose into a single
appRouterat the root. - Export router types for client inference.
Procedure Naming
- Use verbs:
getAll,getById,create,update,delete. - Suffix with
Infinitefor 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()orz.uuid()for IDs. - Define reusable schemas in a shared
schemas.tsif used across routers.
Output Shaping
- Return Prisma models directly when shape matches; use
selectto 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
createCallerfor initial data. - Use
useQuery/useInfiniteQueryfor client-side interactivity. - Invalidate queries with
utils.post.getAll.invalidate()after mutations. - Use
useMutationwithonSuccessfor 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
selectinstead ofincludewhen possible. - Add database indexes for filtered/sorted fields (
@@index([published, createdAt])). - Use
prisma.$queryRawfor complex aggregations. - Enable query logging in development to spot N+1 issues.
Caching Strategies
- Set
staleTimeon React Query (e.g., 60s for lists, 5min for details). - Use Next.js
unstable_cachein 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.useQueriesfor 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
protectedProcedureas base; addadminProcedure,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.jsfor production domains only.
Secrets Management
- Store DATABASE_URL, NEXTAUTH_SECRET, provider secrets in environment variables.
- Never commit
.envfiles; use Vercel/Netlify secret management. - Rotate secrets periodically.
Deployment Notes
Vercel (Recommended)
- Native Next.js support, Edge Functions, zero-config.
- Configure
DATABASE_URLwith connection pooling (PgBouncer via Supabase/Neon). - Set
NEXTAUTH_SECRETand OAuth credentials. - Enable
output: 'standalone'innext.config.jsfor 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 deployin CI/CD pipeline before deploy. - Use
prisma migrate diffto 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/trpcendpoint.
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 matchescreateTRPCRouterreturn type.Property 'post' does not exist on type 'AppRouter'— Check router composition inroot.ts.ZodErrorin mutation — Inspecterror.causefor 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.