How to Build a REST API with Next.js Route Handlers
🔍 Want the best deal? Check current prices and availability.
Compare Prices →When you buy through links on our site, we may earn a commission.
How to Build a REST API with Next.js Route Handlers
If you’re building a Next.js application and need an API, you used to have two choices: spin up a separate Express server, or use the Pages Router’s API routes. With the App Router (stable since Next.js 14), there’s a cleaner option: Route Handlers. They let you define API endpoints directly inside your app directory, sharing the same file-based routing as your pages. No extra server, no boilerplate – just a route.ts file and you’re done.
In this tutorial, I’ll walk you through building a complete REST API with Next.js Route Handlers. We’ll cover everything from basic GET endpoints to authentication, error handling, and deployment. By the end, you’ll have a clear picture of when Route Handlers Make sense and when you might want something else.
What Are Next.js Route Handlers?
Route Handlers are the App Router’s replacement for the old pages/api endpoints. They live in app/api/*/route.ts (or .js) and automatically handle HTTP methods based on exported functions.
// app/api/hello/route.ts
export async function GET(request: Request) {
return new Response(JSON.stringify({ message: "Hello World" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
That’s it – no Express setup, no apiRoutes config. The file structure mirrors the URL path: app/api/users/route.ts maps to GET /api/users.
Key features:
- File-based routing – no need to manually define routes.
- HTTP method support – export
GET,POST,PUT,PATCH,DELETE, etc. - Request/Response Web API – uses the standard
RequestandResponseobjects. - Dynamic routes –
[id]folders for parameters. - Middleware – run logic before handlers (via
middleware.tsin the project root). - Edge runtime – deploy to Vercel’s edge network for low latency.
- Streaming – return
ReadableStreamfor real-time data.
Route Handlers are designed for full-stack Next.js apps where the API logic is tightly coupled with the frontend, but they can also serve as a standalone backend for mobile apps or third-party integrations.
Step-by-Step: Building a REST API
Let’s build a simple task manager API with CRUD operations. We’ll use an in-memory store for simplicity, but the patterns work with any database (Postgres, MongoDB, Prisma, etc.).
1. Set Up the Project
Create a new Next.js App Router project:
npx create-next-app@latest task-api --typescript --app
cd task-api
The --app flag enables the App Router. You can also add --tailwind if you want a frontend later.
2. Define the Data Model
We’ll use a plain array as our “database” for now. Create a lib/data.ts file:
// lib/data.ts
export interface Task {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
let tasks: Task[] = [
{ id: "1", title: "Learn Next.js Route Handlers", completed: false, createdAt: new Date() },
];
export function getTasks(): Task[] {
return tasks;
}
export function addTask(title: string): Task {
const task: Task = {
id: String(Date.now()),
title,
completed: false,
createdAt: new Date(),
};
tasks.push(task);
return task;
}
export function updateTask(id: string, updates: Partial): Task | null {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return null;
tasks[index] = { ...tasks[index], ...updates };
return tasks[index];
}
export function deleteTask(id: string): boolean {
const index = tasks.findIndex((t) => t.id === id);
if (index === -1) return false;
tasks.splice(index, 1);
return true;
}
3. Create the Route Handlers
We’ll put all task endpoints under app/api/tasks.
GET /api/tasks – list all tasks:
// app/api/tasks/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getTasks } from "@/lib/data";
export async function GET(request: NextRequest) {
const tasks = getTasks();
return NextResponse.json(tasks);
}
NextResponse.json() is a convenience wrapper around Response.json(). It sets the Content-Type header automatically.
POST /api/tasks – create a new task:
export async function POST(request: NextRequest) {
const body = await request.json();
const { title } = body;
if (!title || typeof title !== "string") {
return NextResponse.json(
{ error: "Title is required and must be a string" },
{ status: 400 }
);
}
const task = addTask(title);
return NextResponse.json(task, { status: 201 });
}
We added basic validation. Route Handlers can read the request body via request.json() (or request.text(), request.formData()).
Dynamic route for single task – create app/api/tasks/[id]/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { getTasks, updateTask, deleteTask } from "@/lib/data";
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const tasks = getTasks();
const task = tasks.find((t) => t.id === params.id);
if (!task) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
return NextResponse.json(task);
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const body = await request.json();
const updated = updateTask(params.id, body);
if (!updated) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
return NextResponse.json(updated);
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const deleted = deleteTask(params.id);
if (!deleted) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
return NextResponse.json({ message: "Task deleted" });
}
Dynamic segments work exactly like page routes: [id] captures the value and passes it to params.id.
4. Test the API
Run npm run dev and test with curl or a tool like Postman:
curl http://localhost:3000/api/tasks
curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"title":"New task"}'
curl http://localhost:3000/api/tasks/1
curl -X PUT http://localhost:3000/api/tasks/1 -H "Content-Type: application/json" -d '{"completed":true}'
curl -X DELETE http://localhost:3000/api/tasks/1
Everything works. No Express, no extra config.
Adding Authentication with Middleware
Route Handlers can be protected by Next.js Middleware, which runs before every request. Here’s a simple API key check:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Only protect API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
const apiKey = request.headers.get("x-api-key");
if (apiKey !== process.env.API_KEY) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
return NextResponse.next();
}
export const config = {
matcher: "/api/:path*",
};
This runs on the Edge runtime, so it’s fast but has some limitations (no access to Node.js APIs like fs). For more complex auth (e.g., JWT verification), you can write logic inside each route handler or use a library like NextAuth.js.
Edge vs Node.js Runtime
By default, Route Handlers run on the Node.js runtime. You can opt into the Edge runtime by exporting a runtime config:
export const runtime = "edge"; // or "nodejs"
Edge runtime is faster (deploys to Vercel’s edge network) but has constraints: no Buffer, no process.env at runtime (use process.env at build time or NextRequest’s geo and ip), and limited Node.js APIs. For most CRUD APIs, Node.js runtime is fine. Use Edge when you need global low latency for read-heavy endpoints.
Comparison: Route Handlers vs Other API Approaches
| Feature | Next.js Route Handlers | Express.js (standalone) | Next.js Pages Router API |
|---|---|---|---|
| File-based routing | Yes (App Router) | No (manual) | Yes (Pages Router) |
| HTTP methods | Export functions | app.get/post/... | Export default handler with req.method switch |
| Middleware | Global middleware.ts | app.use() | No built-in middleware; use next-connect |
| Edge runtime | Yes (opt-in) | No | No |
| Streaming | Yes (Response stream) | Yes (with pipe) | No |
| Typed request/response | Web API (Request/Response) | Express req/res | IncomingMessage/ServerResponse |
| Authentication libraries | NextAuth.js, Clerk, custom | Passport, JWT | NextAuth.js, custom |
| Learning curve | Medium (App Router) | Low | Medium (Pages Router) |
| Hosting options | Vercel, Netlify, self-hosted | Any Node.js host | Same as Route Handlers |
| Pricing (free tier limits) | Vercel: 100k requests/month, 100GB bandwidth; Netlify: 125k requests/month | Self-hosted: server cost | Same as Route Handlers |
Verdict on the table: Route Handlers are the most integrated option for full-stack Next.js apps. They share the same routing, middleware, and deployment as your frontend. Express.js is better if you want a separate backend that can be scaled independently. Pages Router API routes are legacy but still work – just less flexible.
Pros and Cons of Next.js Route Handlers
Pros
- No extra server – your API lives inside your Next.js project. One
package.json, one deploy. - File-based routing – URL structure is obvious from the folder tree.
- Web API standard –
RequestandResponseare modern and familiar if you’ve used fetch. - Edge ready – drop-in for global performance.
- Streaming support – great for real-time data (e.g., SSE, AI responses).
- Middleware – centralize auth, logging, rate limiting.
Cons
- Tight coupling – API logic is mixed with frontend code. Hard to reuse if you later extract a separate backend.
- No built-in validation – you have to manually parse and validate request bodies (though libraries like Zod help).
- Limited Node.js APIs on Edge – if you need
fs,crypto(Node.js version), or database drivers that rely on Node.js streams, Edge won’t work. - Not ideal for microservices – each Next.js app is a monolith. If you need independent API services, Express or Fastify are better.
- Learning curve – developers new to the App Router may find
route.tsfiles confusing at first.
Verdict: Should You Use Next.js Route Handlers?
Yes, if:
- You’re already building a full-stack Next.js app and need a simple API.
- Your API is tightly coupled with your frontend (e.g., server data fetching, form submissions).
- You want to deploy everything on Vercel or Netlify with minimal configuration.
- You’re building a small-to-medium project and don’t need a separate backend team.
No, if:
- You need a standalone API that multiple frontends (mobile, web, third-party) will consume.
- You want to scale the API independently from the frontend.
- You rely heavily on Node.js-specific APIs (e.g.,
fs,child_process, or database ORMs that require Node.js streams). - You prefer Express.js middleware patterns and want to reuse existing Express middleware.
For most indie hackers and small teams building a Next.js app, Route Handlers are the fastest way to get an API up and running. They’re not a replacement for a dedicated backend framework, but they don’t need to be.
If you’re deploying on Vercel, the free tier handles 100k requests per month – enough for a side project. Deploy on Vercel ->
FAQ
1. Can I use Route Handlers with the Pages Router?
No. Route Handlers are exclusive to the App Router. If you’re using the Pages Router, you’ll need to stick with pages/api or migrate to the App Router.
2. How do I handle CORS?
You can set CORS headers in a middleware or in each route handler:
// middleware.ts
export function middleware(request: NextRequest) {
const response = NextResponse.next();
response.headers.set("Access-Control-Allow-Origin", "*");
response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE");
return response;
}
3. Can I use Route Handlers with Prisma or other ORMs?
Yes. Prisma works in the Node.js runtime. Just import your Prisma client in route handlers and use it as usual. For Edge runtime, use Prisma Accelerate or a serverless-compatible driver.
4. How do I read query parameters?
Use `
🔍 Want the best deal? Check current prices and availability.
Compare Prices →