Building a Production-Ready API with tRPC, Prisma, and Next.js App Router

Building a Production-Ready API with tRPC, Prisma, and Next.js App Router
Building modern APIs requires balancing developer experience, type safety, and performance. Traditional REST APIs lack end-to-end type safety, while GraphQL adds significant complexity. tRPC offers an elegant solution: full-stack TypeScript type safety without code generation, combined with Prisma for database management and Next.js 15 for server and client rendering.
By the end of this tutorial, you'll have built a complete task management API with authentication, database operations, and a React client—all with complete type safety from database to UI.
Why tRPC + Prisma?
tRPC (TypeScript Remote Procedure Call) eliminates the API contract problem. When you change your backend, your frontend types update automatically. No manual API documentation, no OpenAPI schemas, no code generation steps.
Prisma provides a type-safe ORM with migrations, introspection, and an excellent DX. Combined with tRPC, you get type safety from database schema to React components.
Real-world benefits:
- Catch errors at compile time, not runtime
- Refactor with confidence (rename a field, TypeScript finds all usages)
- Autocomplete everywhere
- Reduced boilerplate compared to REST or GraphQL
Prerequisites
Before starting, ensure you have:
- Node.js 18+ (check: `node -v`)
- npm, yarn, or pnpm (we'll use pnpm)
- PostgreSQL installed locally or a cloud database
- Basic TypeScript and React knowledge
- Familiarity with Next.js fundamentals
Install PostgreSQL if needed:
# macOS
brew install postgresql@15
brew services start postgresql@15
# Ubuntu/Debian
sudo apt update && sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresqlStep 1: Project Setup
Create a new Next.js project:
pnpm create next-app@latest trpc-task-api --typescript --tailwind --app --eslint
cd trpc-task-apiInstall dependencies:
pnpm add @trpc/server @trpc/client @trpc/react-query @trpc/next @tanstack/react-query@^5
pnpm add @prisma/client zod superjson
pnpm add -D prismaInitialize Prisma:
pnpx prisma initStep 2: Database Schema
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?
createdAt DateTime @default(now())
tasks Task[]
}
model Task {
id String @id @default(cuid())
title String
description String?
status TaskStatus @default(TODO)
priority Priority @default(MEDIUM)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
}
enum TaskStatus {
TODO
IN_PROGRESS
DONE
}
enum Priority {
LOW
MEDIUM
HIGH
URGENT
}Update `.env`:
DATABASE_URL="postgresql://postgres:password@localhost:5432/trpc_tasks"
Run migration:
pnpx prisma migrate dev --name initStep 3: Prisma Client
Create `lib/prisma.ts`:
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined }
export const prisma = globalForPrisma.prisma ?? new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error'],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prismaStep 4: tRPC Setup
Create `lib/trpc/trpc.ts`:
import { initTRPC, TRPCError } from '@trpc/server'
import { prisma } from '@/lib/prisma'
import superjson from 'superjson'
export const createTRPCContext = async (opts: { headers: Headers }) => {
return {
prisma,
userId: opts.headers.get('x-user-id') || undefined,
}
}
const t = initTRPC.context<typeof createTRPCContext>().create({
transformer: superjson,
})
export const router = t.router
export const publicProcedure = t.procedure
export const protectedProcedure = t.procedure.use(async (opts) => {
if (!opts.ctx.userId) {
throw new TRPCError({ code: 'UNAUTHORIZED' })
}
return opts.next({ ctx: { ...opts.ctx, userId: opts.ctx.userId } })
})Step 5: Task Router
Create `lib/trpc/routers/task.ts`:
import { z } from 'zod'
import { router, protectedProcedure } from '../trpc'
import { TaskStatus, Priority } from '@prisma/client'
import { TRPCError } from '@trpc/server'
export const taskRouter = router({
list: protectedProcedure
.input(z.object({
status: z.nativeEnum(TaskStatus).optional(),
limit: z.number().min(1).max(100).default(50)
}))
.query(async ({ ctx, input }) => {
return await ctx.prisma.task.findMany({
where: {
userId: ctx.userId,
...(input.status && { status: input.status })
},
orderBy: [{ priority: 'desc' }, { createdAt: 'desc' }],
take: input.limit,
})
}),
getById: protectedProcedure
.input(z.object({ id: z.string().cuid() }))
.query(async ({ ctx, input }) => {
const task = await ctx.prisma.task.findUnique({ where: { id: input.id } })
if (!task || task.userId !== ctx.userId) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
return task
}),
create: protectedProcedure
.input(z.object({
title: z.string().min(1).max(200),
description: z.string().max(2000).optional(),
priority: z.nativeEnum(Priority).default('MEDIUM'),
}))
.mutation(async ({ ctx, input }) => {
return await ctx.prisma.task.create({
data: { ...input, userId: ctx.userId },
})
}),
update: protectedProcedure
.input(z.object({
id: z.string().cuid(),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
status: z.nativeEnum(TaskStatus).optional(),
priority: z.nativeEnum(Priority).optional(),
}))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
const existing = await ctx.prisma.task.findUnique({ where: { id } })
if (!existing || existing.userId !== ctx.userId) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
return await ctx.prisma.task.update({ where: { id }, data })
}),
delete: protectedProcedure
.input(z.object({ id: z.string().cuid() }))
.mutation(async ({ ctx, input }) => {
const existing = await ctx.prisma.task.findUnique({ where: { id: input.id } })
if (!existing || existing.userId !== ctx.userId) {
throw new TRPCError({ code: 'NOT_FOUND' })
}
await ctx.prisma.task.delete({ where: { id: input.id } })
return { success: true }
}),
stats: protectedProcedure.query(async ({ ctx }) => {
const [total, todo, inProgress, done] = await Promise.all([
ctx.prisma.task.count({ where: { userId: ctx.userId } }),
ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'TODO' } }),
ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'IN_PROGRESS' } }),
ctx.prisma.task.count({ where: { userId: ctx.userId, status: 'DONE' } }),
])
return { total, todo, inProgress, done }
}),
})Step 6: Root Router
Create `lib/trpc/root.ts`:
import { router } from './trpc'
import { taskRouter } from './routers/task'
export const appRouter = router({
task: taskRouter,
})
export type AppRouter = typeof appRouterStep 7: API Route
Create `app/api/trpc/[trpc]/route.ts`:
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
import { appRouter } from '@/lib/trpc/root'
import { createTRPCContext } from '@/lib/trpc/trpc'
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: '/api/trpc',
req,
router: appRouter,
createContext: createTRPCContext,
})
export { handler as GET, handler as POST }Step 8: Client Setup
Create `lib/trpc/client.ts`:
import { createTRPCReact } from '@trpc/react-query'
import type { AppRouter } from './root'
export const trpc = createTRPCReact<AppRouter>()Create `app/providers.tsx`:
'use client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { httpBatchLink } from '@trpc/client'
import { trpc } from '@/lib/trpc/client'
import { useState } from 'react'
import superjson from 'superjson'
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(() => new QueryClient())
const [trpcClient] = useState(() =>
trpc.createClient({
links: [
httpBatchLink({
url: \`\${getBaseUrl()}/api/trpc\`,
transformer: superjson,
headers() {
return { 'x-user-id': 'demo-user-123' }
},
}),
],
})
)
return (
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</trpc.Provider>
)
}
function getBaseUrl() {
if (typeof window !== 'undefined') return ''
if (process.env.VERCEL_URL) return \`https://\${process.env.VERCEL_URL}\`
return \`http://localhost:\${process.env.PORT ?? 3000}\`
}Update `app/layout.tsx`:
import { Providers } from './providers'
import './globals.css'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body><Providers>{children}</Providers></body>
</html>
)
}Step 9: UI Component
Create `app/tasks/page.tsx`:
'use client'
import { trpc } from '@/lib/trpc/client'
import { useState } from 'react'
export default function TasksPage() {
const [title, setTitle] = useState('')
const utils = trpc.useUtils()
const { data: tasks, isLoading } = trpc.task.list.useQuery({ limit: 50 })
const { data: stats } = trpc.task.stats.useQuery()
const createTask = trpc.task.create.useMutation({
onSuccess: () => {
utils.task.list.invalidate()
utils.task.stats.invalidate()
setTitle('')
},
})
const updateTask = trpc.task.update.useMutation({
onSuccess: () => {
utils.task.list.invalidate()
utils.task.stats.invalidate()
},
})
const deleteTask = trpc.task.delete.useMutation({
onSuccess: () => {
utils.task.list.invalidate()
utils.task.stats.invalidate()
},
})
if (isLoading) return <div className="p-8">Loading...</div>
return (
<div className="max-w-4xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Task Manager</h1>
{stats && (
<div className="grid grid-cols-4 gap-4 mb-8">
<div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.total}</div><div className="text-sm text-gray-600">Total</div></div>
<div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.todo}</div><div className="text-sm text-gray-600">To Do</div></div>
<div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.inProgress}</div><div className="text-sm text-gray-600">In Progress</div></div>
<div className="p-4 border rounded"><div className="text-2xl font-bold">{stats.done}</div><div className="text-sm text-gray-600">Done</div></div>
</div>
)}
<form onSubmit={(e) => { e.preventDefault(); createTask.mutate({ title }) }} className="mb-8">
<div className="flex gap-2">
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder="New task..." className="flex-1 px-4 py-2 border rounded" />
<button type="submit" disabled={createTask.isPending}
className="px-6 py-2 bg-blue-600 text-white rounded">Add</button>
</div>
</form>
<div className="space-y-2">
{tasks?.map((task) => (
<div key={task.id} className="flex items-center gap-4 p-4 border rounded">
<input type="checkbox" checked={task.status === 'DONE'}
onChange={() => updateTask.mutate({ id: task.id, status: task.status === 'DONE' ? 'TODO' : 'DONE' })} />
<div className="flex-1">
<h3 className={\`font-medium \${task.status === 'DONE' ? 'line-through' : ''}\`}>{task.title}</h3>
</div>
<span className="px-2 py-1 text-xs rounded bg-gray-100">{task.priority}</span>
<button onClick={() => deleteTask.mutate({ id: task.id })} className="text-red-600">Delete</button>
</div>
))}
</div>
</div>
)
}Step 10: Run the Application
pnpm devVisit http://localhost:3000/tasks
Production Best Practices
1. Authentication
Replace demo header with NextAuth.js or Clerk:
import { getServerSession } from 'next-auth'
export const createTRPCContext = async () => {
const session = await getServerSession()
return { prisma, userId: session?.user?.id }
}2. Rate Limiting
Use Upstash or similar:
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
})3. Error Monitoring
Add Sentry or similar:
const t = initTRPC.create({
errorFormatter({ error }) {
// Log to Sentry
return error
},
})4. Database Pooling
For serverless, use Prisma Accelerate or connection poolers like PgBouncer.
5. Caching
Implement Redis caching for frequently accessed data.
Deployment
Vercel
pnpx vercelSet `DATABASE_URL` in environment variables.
Docker
FROM node:20-alpine
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN npm install -g pnpm && pnpm install
COPY . .
RUN pnpx prisma generate
RUN pnpm build
EXPOSE 3000
CMD ["pnpm", "start"]Summary
You've built a production-ready API with:
✅ End-to-end type safety from database to UI ✅ Input validation with Zod ✅ Authentication and authorization ✅ Database migrations with Prisma ✅ React Query caching and optimistic updates ✅ Request batching for performance
Key Takeaways
- tRPC eliminates API contracts – types sync automatically
- Prisma types flow to tRPC – database schema drives API types
- Middleware enables reusable patterns – auth, logging, validation
- React Query powers the client – caching, retries, mutations
- TypeScript catches errors early – compile-time safety
Next Steps
- Add WebSocket subscriptions for real-time updates
- Implement file uploads
- Add OpenAPI endpoint for external integrations
- Build mobile app with React Native + tRPC
- Add CQRS patterns with separate databases
Need Help Building Production APIs?
Noqta specializes in building robust, type-safe APIs and full-stack applications:
- API Integration – Connect systems with reliable APIs
- Web Development – Next.js, React, Node.js applications
- Quality Assurance – Testing, audits, optimization
Book a free consultation: Contact Noqta
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

AI SDK 4.0: New Features and Use Cases
Discover the new features and use cases of AI SDK 4.0, including PDF support, computer use, and more.

Building a Conversational AI App with Next.js
Learn how to build a web application that enables real-time voice conversations with AI agents using Next.js and ElevenLabs.

Building a Multi-Tenant App with Next.js
Learn how to build a full-stack multi-tenant application using Next.js, Vercel, and other modern technologies.