In this guide
You built a Next.js frontend and a separate Express backend. Two repos (or two folders), two dev servers, CORS configuration to make them talk, and two deployments to manage. For many projects, that second server is unnecessary weight — Next.js can serve your API itself.
API Routes (and their successor, Route Handlers) let your Next.js app respond to HTTP requests directly: same project, same deployment, no CORS. This guide explains how they work, when they fit, and where their limits are.
The mental model
A file in a special directory becomes an HTTP endpoint:
app/api/users/route.ts -> GET/POST https://yourapp.com/api/users
app/api/users/[id]/route.ts -> GET/PUT/DELETE https://yourapp.com/api/users/42
The filesystem is the router — the same convention Next.js uses for pages. Dynamic segments in square brackets become URL parameters. This colocation is the appeal: your frontend and its backend live in one codebase and deploy as one unit.
Note: The older Pages Router used
pages/api/files with(req, res)handlers. The App Router (Next.js 13+) usesapp/api/**/route.tswith Web-standardRequest/Response. This guide covers the App Router — use it for new projects.
Your first Route Handler
// app/api/users/route.ts
import { NextResponse } from 'next/server';
// GET /api/users
export async function GET() {
const users = await db.user.findMany();
return NextResponse.json(users);
}
// POST /api/users
import { NextRequest } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.email || !body.name) {
return NextResponse.json(
{ error: 'name and email are required' },
{ status: 400 }
);
}
const user = await db.user.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
Export a function named after the HTTP method (GET, POST, PUT, PATCH, DELETE); Next.js wires it up. NextResponse.json() builds the JSON response with status codes. The handler receives a standard web Request — request.json(), request.headers, request.url all work as on any platform.
Dynamic routes and query params
// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: Number(params.id) },
});
if (!user) {
return NextResponse.json({ error: 'not found' }, { status: 404 });
}
return NextResponse.json(user);
}
URL segments arrive via the second argument's params; query strings via request.nextUrl.searchParams.get('q'). Validate params.id before using it — it is a string from the URL, and Number("abc") is NaN, not an error.
When API Routes are the right call
- Your frontend needs a backend for frontend (BFF). Form submissions, data fetching for your own pages, small CRUD — the classic student project backend.
- You want one deployment. Vercel, or a single VPS running
next start, serves pages and API together. No CORS, no second server to babysit. - Server-side secrets. API keys and database credentials stay in Route Handlers (server-only) instead of leaking into browser bundles.
- Prototyping speed. New endpoint = new file. No separate backend repo to scaffold.
Where they hit limits
| Concern | Reality |
|---|---|
| Long-running work | Serverless deployments cap execution time (often 10–60s). ML inference, video processing, big reports need a separate worker or message queue. |
| WebSockets | Route Handlers are request/response — no persistent connections. Real-time needs a separate server (see the WebSockets guide). |
| Heavy traffic | One Next.js server handling pages + API + SSR can become the bottleneck; a dedicated API scales independently. |
| Non-JS services | If your ML model serves from Python or your team speaks Go, a separate API service is cleaner. |
The honest rule: start with Route Handlers; extract a standalone API when a specific limit bites. Most student projects never hit the limits.
Database access pattern
Connect once and reuse the client across requests — do not open a new database connection per request:
// lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const db = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = db;
}
In development, Next.js hot-reloads modules constantly; without the global cache you leak a database connection per reload until the database refuses new ones. This singleton pattern is the standard fix (shown here with Prisma; the idea applies to any client).
Authentication in Route Handlers
Route Handlers read cookies and headers like any server code:
- Verify the session/JWT from the
Authorizationheader or httpOnly cookie on every protected route. - Never trust client-supplied identity — look up the user from the verified token.
- For the token mechanics: JWT Refresh Token Rotation Done Right; for the concepts: JWT Authentication Explained.
Middleware (middleware.ts) can protect whole route groups — redirect unauthenticated users before the handler runs — keeping auth logic out of every file.
Caching and rendering interplay
Route Handlers support the same caching primitives as the rest of Next.js:
export const dynamic = 'force-dynamic'— always run fresh (for personalized data).revalidate— cache the response and refresh on an interval (for semi-static data like a product list).
Default caching in App Router surprises beginners: a GET handler can be statically cached at build time. If your endpoint returns per-user data and shows stale results, force-dynamic is usually the fix.
Route Handlers vs Server Actions
Next.js offers two server-side primitives and beginners mix them up. The rule of thumb:
| Route Handlers | Server Actions | |
|---|---|---|
| What | HTTP endpoints (app/api/**/route.ts) |
Async functions called directly from components |
| Use for | Public APIs, webhooks, third-party callbacks, anything needing raw HTTP control | Form submissions and mutations inside your own app |
| Called by | Any HTTP client | Your React components (via action or useTransition) |
| Response | Full control (status codes, headers, streaming) | Return values to the component |
A contact form in your app? Server Action — no endpoint file, no fetch boilerplate, progressive enhancement built in. A Stripe webhook or a mobile app calling your backend? Route Handler — you need the HTTP semantics. They coexist fine: many apps use Server Actions for their own UI and Route Handlers for everything external.
Common mistakes
- Putting secrets in client components. Anything imported by a client component ships to the browser. Database clients and API keys live in Route Handlers and server components only.
- New DB connection per request. Use the singleton pattern above.
- No input validation. Route Handlers are a public HTTP surface — validate bodies and params (Zod pairs well with TypeScript) instead of trusting them.
- Long tasks in handlers. A 5-minute report generation will hit execution timeouts on serverless. Queue it, return 202, let the client poll or use WebSockets for completion.
- Forgetting
force-dynamic. Stale personalized data from build-time caching is a confusing bug the first time. - CORS confusion. Same-origin requests need no CORS. You only configure CORS when other origins call your API — which, with a colocated frontend, is nobody.
Quick checklist
- Routes organized under
app/api/mirroring your resource model - Input validation on every handler that accepts data
- Database client as a singleton, not per-request
- Auth verified server-side on protected routes (middleware for groups)
- Caching behavior deliberate (
force-dynamicwhere data is personal) - Long-running work moved to queues/workers, not handlers
Where to go from here
- Framework choice: Next.js vs React: which to learn.
- Type your handlers: TypeScript for JavaScript Developers.
- Serve over HTTPS: how to get a free SSL certificate.
- Deploy the whole thing: how to deploy a MERN app on a VPS.
- More Next.js topics in the Web Development branch hub.