How to Build a REST API with Next.js App Router
A step-by-step guide to building type-safe REST APIs using Next.js Route Handlers, Prisma, and PostgreSQL — from setup to deployment.
Next.js isn't just a frontend framework. With the App Router's Route Handlers, you can build full-featured REST APIs that run on the Edge or in Node.js — all within the same project.
In this guide, I'll walk through building a production-ready REST API for a task management app.
Why Route Handlers?
Before the App Router, Next.js API routes (/pages/api) were functional but limited. Route Handlers (/app/api) improve on them with:
- File-based routing mirroring the frontend
- Support for Edge Runtime
- Middleware integration
- Type-safe request/response handling
Project Setup
Start by creating the API directory structure:
app/
└── api/
└── tasks/
├── route.ts # GET /api/tasks, POST /api/tasks
└── [id]/
└── route.ts # GET/PUT/DELETE /api/tasks/:id
Connecting to a Database
I'll use Prisma with PostgreSQL. First, install the dependencies:
npm install @prisma/client
npm install -D prisma
npx prisma init
Define your schema:
model Task {
id String @id @default(cuid())
title String
description String?
status String @default("pending")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
Run the migration:
npx prisma migrate dev --name init
Building the Route Handlers
GET /api/tasks — Fetch all tasks
// app/api/tasks/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
const tasks = await prisma.task.findMany({
orderBy: { createdAt: 'desc' },
})
return NextResponse.json(tasks)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch tasks' },
{ status: 500 }
)
}
}
POST /api/tasks — Create a task
export async function POST(request: Request) {
try {
const body = await request.json()
const { title, description } = body
if (!title || typeof title !== 'string') {
return NextResponse.json(
{ error: 'Title is required and must be a string' },
{ status: 400 }
)
}
const task = await prisma.task.create({
data: { title, description },
})
return NextResponse.json(task, { status: 201 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to create task' },
{ status: 500 }
)
}
}
Dynamic Route — GET /api/tasks/[id]
// app/api/tasks/[id]/route.ts
import { NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const task = await prisma.task.findUnique({ where: { id } })
if (!task) {
return NextResponse.json(
{ error: 'Task not found' },
{ status: 404 }
)
}
return NextResponse.json(task)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to fetch task' },
{ status: 500 }
)
}
}
PUT /api/tasks/[id] — Update a task
export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
const body = await request.json()
const task = await prisma.task.update({
where: { id },
data: body,
})
return NextResponse.json(task)
} catch (error) {
return NextResponse.json(
{ error: 'Failed to update task' },
{ status: 500 }
)
}
}
DELETE /api/tasks/[id] — Remove a task
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
try {
await prisma.task.delete({ where: { id } })
return new NextResponse(null, { status: 204 })
} catch (error) {
return NextResponse.json(
{ error: 'Failed to delete task' },
{ status: 500 }
)
}
}
Error Handling Patterns
A robust API needs consistent error handling:
// lib/api-utils.ts
import { NextResponse } from 'next/server'
export function handleApiError(error: unknown) {
console.error('API Error:', error)
if (error instanceof PrismaClientKnownRequestError) {
if (error.code === 'P2025') {
return NextResponse.json(
{ error: 'Resource not found' },
{ status: 404 }
)
}
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
Middleware for Authentication
Protect your API routes with middleware:
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// Check for API routes
if (request.nextUrl.pathname.startsWith('/api')) {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
return NextResponse.next()
}
export const config = {
matcher: '/api/:path*',
}
Testing with Postman or Bruno
Once your API is running, test each endpoint:
GET http://localhost:3000/api/tasks
POST http://localhost:3000/api/tasks Body: { "title": "My Task" }
GET http://localhost:3000/api/tasks/clx...
PUT http://localhost:3000/api/tasks/clx... Body: { "status": "completed" }
DELETE http://localhost:3000/api/tasks/clx...
Deployment Considerations
When deploying to Vercel:
- Set
DATABASE_URLin environment variables - Run
npx prisma generateduring build - Consider using Vercel Postgres for managed database
- Enable Edge Functions for lower-latency API routes
Conclusion
Next.js Route Handlers provide a seamless way to build APIs alongside your frontend. The file-based routing, type safety, and middleware support make it a solid choice for full-stack applications.
For the complete source code, check out the project repository. The key takeaway is: you don't need a separate backend server for most applications — Next.js can handle it all.