API Routes & Route Handlers
API Routes in Next.js
Next.js lets you build your backend API right inside your app — no separate server needed. Any file named route.ts inside src/app/api/ becomes an HTTP endpoint. This is perfect for handling form submissions, authenticating users, talking to databases, or building a full REST API.
Creating Your First Route
// src/app/api/hello/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ message: "Hello World" });
}
Visit /api/hello — you get {"message": "Hello World"}.
HTTP Methods
Export a function for each HTTP method you want to handle:
// src/app/api/courses/route.ts
import { NextRequest, NextResponse } from "next/server";
// GET /api/courses
export async function GET(request: NextRequest) {
const courses = await db.course.findMany();
return NextResponse.json(courses);
}
// POST /api/courses
export async function POST(request: NextRequest) {
const body = await request.json();
const course = await db.course.create({
data: {
title: body.title,
description: body.description,
},
});
return NextResponse.json(course, { status: 201 });
}
Dynamic API Routes
// src/app/api/courses/[id]/route.ts
interface Context {
params: { id: string };
}
// GET /api/courses/123
export async function GET(request: NextRequest, { params }: Context) {
const course = await db.course.findUnique({
where: { id: params.id },
});
if (!course) {
return NextResponse.json({ error: "Course not found" }, { status: 404 });
}
return NextResponse.json(course);
}
// PATCH /api/courses/123
export async function PATCH(request: NextRequest, { params }: Context) {
const body = await request.json();
const course = await db.course.update({
where: { id: params.id },
data: body,
});
return NextResponse.json(course);
}
// DELETE /api/courses/123
export async function DELETE(request: NextRequest, { params }: Context) {
await db.course.delete({ where: { id: params.id } });
return new Response(null, { status: 204 });
}
Reading Request Data
export async function POST(request: NextRequest) {
// JSON body
const body = await request.json();
// Form data
const formData = await request.formData();
const email = formData.get("email") as string;
// Query parameters
const url = new URL(request.url);
const page = url.searchParams.get("page") ?? "1";
const limit = url.searchParams.get("limit") ?? "10";
// Headers
const authHeader = request.headers.get("authorization");
// Cookies
const token = request.cookies.get("session")?.value;
}
A Complete REST API
Here's a realistic user management API:
// src/app/api/users/route.ts
// GET /api/users?page=1&search=alice
export async function GET(request: NextRequest) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
const search = url.searchParams.get("search") ?? "";
const limit = 20;
const skip = (page - 1) * limit;
const where = search
? { OR: [{ name: { contains: search } }, { email: { contains: search } }] }
: {};
const [users, total] = await Promise.all([
db.user.findMany({ where, skip, take: limit, orderBy: { createdAt: "desc" } }),
db.user.count({ where }),
]);
return NextResponse.json({
users,
pagination: { page, limit, total, pages: Math.ceil(total / limit) },
});
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate
if (!body.email || !body.name) {
return NextResponse.json(
{ error: "email and name are required" },
{ status: 400 }
);
}
// Check for existing user
const existing = await db.user.findUnique({ where: { email: body.email } });
if (existing) {
return NextResponse.json(
{ error: "Email already registered" },
{ status: 409 }
);
}
const user = await db.user.create({
data: { name: body.name, email: body.email },
select: { id: true, name: true, email: true, createdAt: true },
});
return NextResponse.json(user, { status: 201 });
}
Authentication Check
Protect routes by checking for a valid session:
// src/lib/auth.ts
import { cookies } from "next/headers";
import { verifyToken } from "./jwt";
export async function requireAuth() {
const cookieStore = cookies();
const token = cookieStore.get("session")?.value;
if (!token) return null;
try {
return verifyToken(token); // returns user payload
} catch {
return null;
}
}
// src/app/api/dashboard/route.ts
import { requireAuth } from "@/lib/auth";
export async function GET(request: NextRequest) {
const user = await requireAuth();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const stats = await getDashboardStats(user.id);
return NextResponse.json(stats);
}
Middleware for Auth
For routes that all require auth, use Next.js middleware instead of checking in every route:
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "./lib/jwt";
export function middleware(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
// Redirect to login
return NextResponse.redirect(new URL("/login", request.url));
}
try {
verifyToken(token);
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
// Run middleware on these paths
export const config = {
matcher: ["/dashboard/:path*", "/api/dashboard/:path*"],
};
CORS Headers
For routes called from external origins:
// src/app/api/public/route.ts
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers: corsHeaders });
}
export async function GET() {
const data = await getPublicData();
return NextResponse.json(data, { headers: corsHeaders });
}
Handling File Uploads
// src/app/api/upload/route.ts
import { writeFile } from "fs/promises";
import path from "path";
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get("file") as File;
if (!file) {
return NextResponse.json({ error: "No file provided" }, { status: 400 });
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
const filename = `${Date.now()}-${file.name}`;
const filepath = path.join(process.cwd(), "public/uploads", filename);
await writeFile(filepath, buffer);
return NextResponse.json({ url: `/uploads/${filename}` });
}
For production, use a cloud storage service (AWS S3, Cloudflare R2, Vercel Blob) instead of writing to the filesystem.
Error Handling Pattern
Wrap route handlers to catch unexpected errors:
function withErrorHandling(handler: Function) {
return async function(request: NextRequest, context: any) {
try {
return await handler(request, context);
} catch (error) {
console.error("API Error:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
);
}
};
}
export const GET = withErrorHandling(async (request: NextRequest) => {
const users = await db.user.findMany();
return NextResponse.json(users);
});
Next lesson: Node.js and Express — building backend servers with JavaScript.
Get this course's notes on Telegram!
Free cheat sheets, summaries & practice exercises