How to Build a Backend with Next.js Route Handlers (No Express)
You don't need Express, Fastify, or a separate API server anymore. Next.js 13+ ships with everything you need to build a production-grade backend — right inside your frontend project.
Loading articles...
You don't need Express, Fastify, or a separate API server anymore. Next.js 13+ ships with everything you need to build a production-grade backend — right inside your frontend project.

For years, the default architecture for a JavaScript full-stack app looked something like this:
React App → Express API → Database
(port 3000) (port 4000)
Two repos (or a monorepo with complex tooling). Two deployment targets. CORS headers everywhere. Environment variable duplication. Context-switching between two different mental models of routing.
Next.js Route Handlers collapse this into a single project. Your API lives in the same codebase as your UI, shares the same TypeScript types, the same environment variables, and deploys as one unit — to Vercel, a Docker container, or any Node.js host.
This isn't a toy feature. Route Handlers support full HTTP semantics, streaming responses, middleware-style logic, authentication, database connections, and everything you'd expect from a real backend. They're just... built in.
Introduced in Next.js 13 with the App Router, Route Handlers are files named route.ts (or route.js) placed inside the app/ directory. They export named async functions corresponding to HTTP methods: GET, POST, PUT, PATCH, DELETE, HEAD, and OPTIONS.
app/
├── api/
│ ├── users/
│ │ ├── route.ts ← handles /api/users
│ │ └── [id]/
│ │ └── route.ts ← handles /api/users/:id
│ └── auth/
│ └── route.ts ← handles /api/auth
└── page.tsx
Important: A folder can contain either a
page.tsxor aroute.ts— not both. The convention separates UI routes from API routes cleanly.
Let's start with the simplest possible example — a health check endpoint.
// app/api/health/route.ts
import { NextResponse } from 'next/server'
export async function GET() {
return NextResponse.json(
{ status: 'ok', timestamp: new Date().toISOString() },
{ status: 200 }
)
}
Hit GET /api/health and you'll receive:
{
"status": "ok",
"timestamp": "2025-04-22T14:30:00.000Z"
}
No app.listen(). No res.json(). No Express middleware stack to configure. Just a function that returns a Response.
Route Handlers are built on the Web Fetch API — the same Request and Response primitives available in browsers, Cloudflare Workers, and Deno. Next.js extends these with NextRequest and NextResponse for convenience, but you can use the standard classes directly if you prefer.
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
// Query parameters
const searchParams = request.nextUrl.searchParams
const query = searchParams.get('q')
const page = Number(searchParams.get('page') ?? '1')
if (!query) {
return NextResponse.json(
{ error: 'Missing required parameter: q' },
{ status: 400 }
)
}
// Your business logic here
const results = await searchDatabase(query, page)
return NextResponse.json({ results, page, query })
}
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
const body = await request.json()
// Validate the body
if (!body.title || !body.content) {
return NextResponse.json(
{ error: 'title and content are required' },
{ status: 422 }
)
}
const post = await db.post.create({
data: {
title: body.title,
content: body.content,
},
})
return NextResponse.json(post, { status: 201 })
}
export async function GET(request: NextRequest) {
// Headers
const authorization = request.headers.get('authorization')
const userAgent = request.headers.get('user-agent')
// Cookies
const sessionToken = request.cookies.get('session')?.value
// ...
}
Route Handlers support the same dynamic segment syntax as page routes. The second argument to any handler function is a context object containing resolved params.
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server'
interface RouteContext {
params: { id: string }
}
export async function GET(
request: NextRequest,
{ params }: RouteContext
) {
const user = await db.user.findUnique({
where: { id: params.id },
})
if (!user) {
return NextResponse.json(
{ error: 'User not found' },
{ status: 404 }
)
}
return NextResponse.json(user)
}
export async function DELETE(
request: NextRequest,
{ params }: RouteContext
) {
await db.user.delete({ where: { id: params.id } })
return new Response(null, { status: 204 })
}
You can also use catch-all segments for nested or wildcard paths:
app/api/files/[...path]/route.ts → /api/files/docs/report/q3.pdf
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
const filePath = params.path.join('/') // "docs/report/q3.pdf"
// ...
}
Without Express middleware, you protect routes by calling your auth logic directly inside the handler — or by extracting it into a reusable utility.
// app/api/profile/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/auth'
export async function GET(request: NextRequest) {
const token = request.headers.get('authorization')?.split(' ')[1]
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const payload = await verifyToken(token)
if (!payload) {
return NextResponse.json({ error: 'Invalid token' }, { status: 403 })
}
const profile = await db.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, name: true },
})
return NextResponse.json(profile)
}
You can wrap handlers in a function to create reusable auth guards:
// lib/with-auth.ts
import { NextRequest, NextResponse } from 'next/server'
import { verifyToken } from '@/lib/auth'
type AuthenticatedHandler = (
request: NextRequest,
context: { params: any; user: { userId: string } }
) => Promise<Response>
export function withAuth(handler: AuthenticatedHandler) {
return async (request: NextRequest, context: { params: any }) => {
const token = request.headers.get('authorization')?.split(' ')[1]
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const user = await verifyToken(token)
if (!user) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 })
}
return handler(request, { ...context, user })
}
}
Usage:
// app/api/dashboard/route.ts
import { withAuth } from '@/lib/with-auth'
export const GET = withAuth(async (request, { user }) => {
const data = await getDashboardData(user.userId)
return Response.json(data)
})
If you're using Auth.js (formerly next-auth), session checking is straightforward:
import { auth } from '@/auth'
import { NextResponse } from 'next/server'
export const GET = auth(async (request) => {
if (!request.auth) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
}
const userId = request.auth.user?.id
// ...
})
Route Handlers run in a Node.js environment (by default), so you can use any database client you'd use in Express: Prisma, Drizzle, Mongoose, pg, mysql2, or any ORM.
// lib/db.ts — singleton pattern to avoid connection exhaustion
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const db =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query'] : [],
})
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
// app/api/products/route.ts
import { db } from '@/lib/db'
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const category = searchParams.get('category')
const products = await db.product.findMany({
where: category ? { category } : undefined,
orderBy: { createdAt: 'desc' },
take: 20,
})
return NextResponse.json(products)
}
export async function POST(request: NextRequest) {
const data = await request.json()
const product = await db.product.create({ data })
return NextResponse.json(product, { status: 201 })
}
Never trust user input. Pair your route handlers with Zod for runtime validation and automatic TypeScript inference.
// app/api/users/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(2).max(100),
role: z.enum(['admin', 'editor', 'viewer']).default('viewer'),
})
export async function POST(request: NextRequest) {
const body = await request.json()
const parsed = CreateUserSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: parsed.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
// parsed.data is fully typed: { email: string, name: string, role: 'admin' | 'editor' | 'viewer' }
const user = await db.user.create({ data: parsed.data })
return NextResponse.json(user, { status: 201 })
}
A failed validation returns a structured error response:
{
"error": "Validation failed",
"details": {
"email": ["Invalid email"],
"name": ["String must contain at least 2 character(s)"]
}
}
Route Handlers support streaming via the Web Streams API — particularly useful for AI-generated content, large data exports, or real-time progress updates.
// app/api/chat/route.ts
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { messages } = await request.json()
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder()
// Example: streaming from an AI SDK
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages,
stream: true,
})
for await (const chunk of completion) {
const text = chunk.choices[0]?.delta?.content ?? ''
controller.enqueue(encoder.encode(text))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
})
}
// app/api/events/route.ts
export async function GET() {
const stream = new ReadableStream({
start(controller) {
const encoder = new TextEncoder()
const send = (data: object) => {
controller.enqueue(
encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
)
}
// Emit events over time
let count = 0
const interval = setInterval(() => {
send({ count: ++count, time: Date.now() })
if (count >= 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
})
}
Set response headers directly on the NextResponse object, or use a utility for DRY cross-origin configuration.
// lib/cors.ts
import { NextResponse } from 'next/server'
export function corsHeaders() {
return {
'Access-Control-Allow-Origin': process.env.ALLOWED_ORIGIN ?? '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}
}
export function corsResponse(data: unknown, init?: ResponseInit) {
return NextResponse.json(data, {
...init,
headers: {
...corsHeaders(),
...(init?.headers ?? {}),
},
})
}
Handle the preflight OPTIONS request in the same route file:
// app/api/data/route.ts
import { corsHeaders, corsResponse } from '@/lib/cors'
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders() })
}
export async function GET() {
return corsResponse({ data: 'hello' })
}
Without a framework plugin, rate limiting is a manual concern. The good news: it's a clean, composable function.
// lib/rate-limit.ts
import { NextRequest, NextResponse } from 'next/server'
const requestCounts = new Map<string, { count: number; resetAt: number }>()
export function rateLimit(
request: NextRequest,
options = { limit: 60, windowMs: 60_000 }
) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'unknown'
const now = Date.now()
const record = requestCounts.get(ip)
if (!record || now > record.resetAt) {
requestCounts.set(ip, { count: 1, resetAt: now + options.windowMs })
return null // allowed
}
if (record.count >= options.limit) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'Retry-After': String(Math.ceil((record.resetAt - now) / 1000)),
},
}
)
}
record.count++
return null // allowed
}
// app/api/contact/route.ts
import { rateLimit } from '@/lib/rate-limit'
export async function POST(request: NextRequest) {
const limited = rateLimit(request, { limit: 5, windowMs: 60_000 })
if (limited) return limited
// handle form submission...
}
Note: The in-memory Map approach works for single-instance deployments. For multi-instance or edge deployments, use a distributed store like Upstash Redis with their
@upstash/ratelimitlibrary.
Build a consistent error response utility to avoid ad-hoc error formats across your handlers:
// lib/api-error.ts
import { NextResponse } from 'next/server'
export class ApiError extends Error {
constructor(
public message: string,
public status: number = 500,
public code?: string
) {
super(message)
}
}
export function handleError(error: unknown) {
console.error(error)
if (error instanceof ApiError) {
return NextResponse.json(
{ error: error.message, code: error.code },
{ status: error.status }
)
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
Usage:
// app/api/orders/[id]/route.ts
import { ApiError, handleError } from '@/lib/api-error'
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const order = await db.order.findUnique({ where: { id: params.id } })
if (!order) {
throw new ApiError('Order not found', 404, 'ORDER_NOT_FOUND')
}
return NextResponse.json(order)
} catch (error) {
return handleError(error)
}
}
Route Handlers handle multipart/form-data natively via the FormData API.
// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function POST(request: NextRequest) {
const formData = await request.formData()
const file = formData.get('file') as File | null
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 })
}
if (file.size > 5 * 1024 * 1024) { // 5MB limit
return NextResponse.json({ error: 'File too large' }, { status: 413 })
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// Save locally (use S3/R2/Cloudinary in production)
const filename = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public/uploads', filename)
await writeFile(path, buffer)
return NextResponse.json({ url: `/uploads/${filename}` }, { status: 201 })
}
// Required: disable Next.js body parsing for file uploads
export const config = {
api: { bodyParser: false },
}
By default, Route Handlers run in a Node.js environment. You can opt into the Edge Runtime for lower latency and global distribution — but with constraints (no Node.js APIs, limited npm packages).
// app/api/geo/route.ts
export const runtime = 'edge' // ← opt in
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const country = request.geo?.country ?? 'unknown'
const city = request.geo?.city ?? 'unknown'
return NextResponse.json({ country, city })
}
| Feature | Node.js Runtime | Edge Runtime |
|---|---|---|
| Cold start | ~100–500ms | ~0–50ms |
| Node.js APIs | ✅ Full access | ❌ Not available |
| Database clients | ✅ Prisma, pg, etc. | ⚠️ HTTP-only (Neon, PlanetScale) |
| Max execution time | ~60s (Vercel) | ~5–30s |
| Global deployment | ❌ Single region | ✅ 300+ locations |
For most CRUD APIs, stay on Node.js. Use Edge for geolocation, A/B testing, auth token verification, and lightweight middleware.
Here's how a production-grade API section of a Next.js project might be organized:
app/
└── api/
├── auth/
│ ├── login/route.ts
│ ├── logout/route.ts
│ └── refresh/route.ts
├── users/
│ ├── route.ts ← GET (list), POST (create)
│ └── [id]/
│ └── route.ts ← GET, PUT, DELETE
├── posts/
│ ├── route.ts
│ └── [slug]/
│ ├── route.ts
│ └── comments/
│ └── route.ts
└── webhooks/
├── stripe/route.ts
└── github/route.ts
lib/
├── db.ts ← Prisma singleton
├── auth.ts ← Token utilities
├── with-auth.ts ← Auth HOF
├── rate-limit.ts ← Rate limiter
├── api-error.ts ← Error classes
└── cors.ts ← CORS helpers
Being informed means knowing the limitations:
middleware.ts runs on the Edge and handles routing-level concerns. For handler-level middleware, you compose higher-order functions manually (as shown above).zod-to-openapi or next-swagger-doc for documentation.You're not locked into Route Handlers. Here's how the options compare for full-stack Next.js:
| Route Handlers | tRPC | Server Actions | |
|---|---|---|---|
| Protocol | HTTP REST | HTTP (RPC-style) | HTTP POST (form-like) |
| Type safety | Manual | ✅ End-to-end | ✅ End-to-end |
| External API | ✅ Easy | ❌ Client-coupling | ❌ Not suitable |
| Learning curve | Low | Medium | Low |
| Best for | Public APIs, webhooks | Internal app APIs | Form mutations, simple actions |
The pragmatic answer: use Server Actions for simple form mutations, tRPC for internal type-safe APIs, and Route Handlers when you need a real HTTP API — for mobile clients, third-party integrations, or webhooks.
Next.js Route Handlers don't just replace Express — they represent a different way of thinking about backend code. Instead of a separate server with its own lifecycle, your API becomes a collection of pure, stateless functions that co-locate with your UI, share your type system, and deploy as part of a single artifact.
The primitives are simple: a file, a function, a Request, a Response. The composability comes from you — higher-order functions for auth, utilities for validation, shared modules for database access. No magic, no framework lock-in.
You'll find that for most applications — CRUD APIs, authentication flows, webhooks, file uploads, and even streaming — Route Handlers are more than enough. And when they're not, they're easy to replace or augment with the right specialized tool for the job.
Start simple. Stay close to the platform. Ship faster.
Related reading: Next.js Route Handlers Docs · Web Fetch API (MDN) · Auth.js with Next.js · Zod Docs

Create elementAI Explainer Videos That Convert With Simple Text Prompts.
Learn More