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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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 postgresql

Step 1: Project Setup

Create a new Next.js project:

pnpm create next-app@latest trpc-task-api --typescript --tailwind --app --eslint
cd trpc-task-api

Install 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 prisma

Initialize Prisma:

pnpx prisma init

Step 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 init

Step 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 = prisma

Step 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 appRouter

Step 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 dev

Visit 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 vercel

Set `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

  1. tRPC eliminates API contracts – types sync automatically
  2. Prisma types flow to tRPC – database schema drives API types
  3. Middleware enables reusable patterns – auth, logging, validation
  4. React Query powers the client – caching, retries, mutations
  5. 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:

Book a free consultation: Contact Noqta


Want to read more tutorials? Check out our latest tutorial on Build Production AI Agents with the Claude Agent SDK and TypeScript.

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