Next.js API routes turn any file under app/api/ into a serverless HTTP endpoint. With the App Router (Next.js 13+), each endpoint is a route.ts file that exports named functions per HTTP method.
This guide covers App Router routes end-to-end: basic GET, all HTTP methods, dynamic segments, auth, errors, and the gotchas you’ll hit in production.
Basic API route
App Router (Next.js 13+)
Create app/api/hello/route.ts:
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ message: 'Hello World' });
}
Verify locally:
Pages Router (legacy)
If you’re still on the Pages Router, create pages/api/hello.ts:
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ message: 'Hello World' });
}
HTTP methods — GET / POST / PUT / DELETE
Export one named function per HTTP method you want to support:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET() {
const users = await db.users.findMany();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const user = await db.users.create({ data: body });
return NextResponse.json(user, { status: 201 });
}
export async function PUT(request: NextRequest) {
const body = await request.json();
// ...
}
export async function DELETE() {
// ...
}
Any method not exported returns 405 Method Not Allowed automatically — Next.js handles that for you.
Request handling
Read query string params
// GET /api/search?q=astro&limit=10
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = searchParams.get('q');
const limit = parseInt(searchParams.get('limit') || '10', 10);
return NextResponse.json({ query, limit });
}
Read JSON body (POST / PUT)
export async function POST(request: NextRequest) {
const body = await request.json();
// body is your parsed JSON
return NextResponse.json({ received: body });
}
Read headers
import { headers } from 'next/headers';
export async function GET() {
const headersList = headers();
const userAgent = headersList.get('user-agent');
return NextResponse.json({ userAgent });
}
Read FormData (file uploads)
export async function POST(request: NextRequest) {
const formData = await request.formData();
const file = formData.get('file') as File;
const buffer = Buffer.from(await file.arrayBuffer());
// ... save buffer to disk / S3 / etc.
return NextResponse.json({ filename: file.name, size: file.size });
}
Response handling
NextResponse.json() is the standard way to return JSON:
return NextResponse.json({ ok: true }, { status: 201 });
For other response types:
// Plain text
return new NextResponse('Hello world', {
headers: { 'Content-Type': 'text/plain' },
});
// Redirect
import { redirect } from 'next/navigation';
redirect('/login'); // 307 by default
// Custom status + headers
return NextResponse.json(
{ error: 'Not found' },
{ status: 404, headers: { 'Cache-Control': 'no-store' } }
);
// Streaming response
const stream = new ReadableStream({
async start(controller) {
controller.enqueue('chunk 1');
controller.enqueue('chunk 2');
controller.close();
},
});
return new NextResponse(stream);
Dynamic routes — [id] segments
Folder name in brackets = dynamic segment. The value is available as a context arg:
// 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.users.findUnique({ where: { id: params.id } });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
return NextResponse.json(user);
}
Catch-all segments use [...slug]:
// app/api/files/[...path]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { path: string[] } }
) {
// /api/files/docs/readme.md → params.path = ['docs', 'readme.md']
const fullPath = params.path.join('/');
return NextResponse.json({ fullPath });
}
Authentication
A common pattern: middleware-style auth check at the top of each handler:
// app/api/private/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/auth';
export async function GET(request: NextRequest) {
const session = await auth();
if (!session || !session.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// Authorized — return private data
return NextResponse.json({ secret: 'authorized!', user: session.user });
}
For shared auth across multiple routes, factor it into a helper:
// lib/api-auth.ts
export async function requireAuth(request: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
return session;
}
// app/api/whatever/route.ts
export async function GET(request: NextRequest) {
const session = await requireAuth(request);
if (session instanceof NextResponse) return session; // early-return on 401
// proceed with session.user
}
Error handling
Wrap handlers in try/catch and return consistent error shapes:
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const result = await db.thing.create({ data: body });
return NextResponse.json(result, { status: 201 });
} catch (error) {
console.error('API error:', error);
if (error instanceof ZodError) {
return NextResponse.json(
{ error: 'Invalid input', issues: error.issues },
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
Best practices
- One file per resource —
app/api/users/route.tsfor collections,app/api/users/[id]/route.tsfor individual records. Keeps URL design clean. - Validate input with Zod —
.parse()at the top of POST/PUT handlers; the catch-block then handles ZodError uniformly. - Don’t put DB logic in route.ts — extract to
lib/modules. Routes become thin transport adapters around your business logic. - Set Cache-Control headers explicitly — Next.js will cache by default in some configurations. For dynamic endpoints,
cache: 'no-store'orrevalidate: 0. - Use Edge runtime when CPU-bound —
export const runtime = 'edge'at the top of route.ts switches from Node to Vercel’s Edge runtime (faster cold starts, but limited Node APIs).
export const runtime = 'edge'; // opt into Edge runtime
export async function GET() {
return NextResponse.json({ message: 'Running on the edge!' });
}
Common pitfalls
| Symptom | Cause | Fix |
|---|---|---|
| 405 Method Not Allowed | You didn’t export that HTTP method | Add export async function POST(...) |
| Route returns cached old data | Next.js cached the response | Set export const dynamic = 'force-dynamic' or use cache: 'no-store' headers |
params.id is undefined | File isn’t in a [id] folder | Move route.ts under app/api/users/[id]/ (note the brackets) |
Auth check returns Promise<NextResponse> | Forgot await on session helper | await auth() |
| Body parsing fails | Trying request.body directly (Node-style) | Use await request.json() instead |
Conclusion
Next.js API routes via the App Router boil down to:
- Create
route.tsinapp/api/<path>/ - Export named functions per HTTP method — GET, POST, PUT, DELETE
- Return
NextResponse.json()(or a streaming response) - Use
[id]for dynamic segments,[...slug]for catch-all - Check auth at the top of any non-public handler
Once you’ve got the file structure right, every API endpoint becomes a 10-line function. The rest is just business logic.