Route Handlers let you create custom API endpoints using Web Request and Response APIs. They replace the Pages Router's API Routes with a more powerful, standards-based approach. Create route.ts files to handle HTTP methods like GET, POST, PUT, DELETE—building RESTful APIs directly in your Next.js app. Let's master API development in the App Router!
What Are Route Handlers?
❌ Pages Router API Routes
// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
res.status(200).json({ message: 'Hello' });
}
// Issues:
// ❌ Node.js-specific APIs
// ❌ Custom NextApiRequest/Response
// ❌ Less flexible
// ❌ Separate from App Router✅ App Router Route Handlers
// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello' });
}
// Benefits:
// ✅ Web standard Request/Response
// ✅ Same APIs as fetch()
// ✅ More portable code
// ✅ Integrated with App Router
// ✅ Streaming support
// ✅ Edge runtime compatibleKey Differences from Pages Router
- Standard APIs: Web Request/Response instead of Node.js-specific
- HTTP method exports: Export GET, POST, etc. functions
- File name: route.ts instead of any name in pages/api/
- Location: Can be anywhere in app/, not just app/api/
- Async by default: All handlers are async
Creating Your First Route Handler
Simple GET Handler
export async function GET(request: Request) {
return new Response('Hello, Next.js!');
}
// Access at: http://localhost:3000/api/hello
// Returns: "Hello, Next.js!" (plain text)
// ✅ Export GET function
// ✅ Receives Request object
// ✅ Returns Response object
// ✅ Async by defaultReturning JSON
export async function GET(request: Request) {
const data = {
message: 'Hello from API',
timestamp: new Date().toISOString(),
};
return Response.json(data);
}
// Access at: /api/data
// Returns:
// {
// "message": "Hello from API",
// "timestamp": "2024-01-15T10:30:00.000Z"
// }
// ✅ Response.json() sets Content-Type: application/json
// ✅ Automatic JSON serialization
// ✅ No manual JSON.stringify neededUsing NextResponse
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const users = [
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
];
return NextResponse.json(users);
}
// NextResponse extends Response with:
// ✅ Cookies helper methods
// ✅ Redirect helpers
// ✅ Rewrite support
// ✅ Next.js-specific features
// Use Response.json() for simple cases
// Use NextResponse.json() for advanced featuresSupported HTTP Methods
All HTTP Methods
// GET - Retrieve data
export async function GET(request: Request) {
const posts = await db.posts.findMany();
return Response.json(posts);
}
// POST - Create new resource
export async function POST(request: Request) {
const body = await request.json();
const post = await db.posts.create({ data: body });
return Response.json(post, { status: 201 });
}
// PUT - Update entire resource
export async function PUT(request: Request) {
const body = await request.json();
const post = await db.posts.update({
where: { id: body.id },
data: body,
});
return Response.json(post);
}
// PATCH - Partial update
export async function PATCH(request: Request) {
const body = await request.json();
const post = await db.posts.update({
where: { id: body.id },
data: body,
});
return Response.json(post);
}
// DELETE - Remove resource
export async function DELETE(request: Request) {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
await db.posts.delete({ where: { id } });
return Response.json({ success: true });
}
// HEAD - Same as GET but no body
export async function HEAD(request: Request) {
return new Response(null, {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
// OPTIONS - Describe communication options
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 200,
headers: {
'Allow': 'GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS',
},
});
}
// ✅ Each method is a separate function
// ✅ Export functions you need
// ✅ Omit unsupported methods (returns 405)Route Handler Rules
Rule 1: Cannot Coexist with page.tsx
// ❌ BAD: Both in same directory
app/
blog/
page.tsx // Page component
route.ts // API handler
// ERROR! Conflict!
// ✅ GOOD: Separate routes
app/
blog/
page.tsx // Page at /blog
api/
blog/
route.ts // API at /api/blog
// A route segment can be EITHER a page OR an API endpointRule 2: Route Handlers Can Be Anywhere
// Route handlers don't have to be in /api
app/
rss.xml/
route.ts // RSS feed at /rss.xml
sitemap.xml/
route.ts // Sitemap at /sitemap.xml
blog/
feed/
route.ts // Blog feed at /blog/feed
api/
posts/
route.ts // Traditional API at /api/posts
// ✅ Place route handlers where they make sense
// ✅ /api is convention, not requirementRule 3: Layout and Template Apply
// Layouts and middleware apply to route handlers
app/
layout.tsx // Root layout
api/
layout.tsx // API layout (optional)
posts/
route.ts // Inherits layouts
middleware.ts // Runs for route handlers too
// ✅ Middleware runs before route handlers
// ✅ Layouts wrap route handlers (if returning HTML)
// ✅ Most APIs return JSON, so layouts don't applyReading Request Data
Request Body (JSON)
export async function POST(request: Request) {
// Parse JSON body
const body = await request.json();
console.log(body);
// { title: 'My Post', content: 'Post content' }
// Validate and use
if (!body.title) {
return Response.json(
{ error: 'Title is required' },
{ status: 400 }
);
}
const post = await db.posts.create({
data: {
title: body.title,
content: body.content,
},
});
return Response.json(post, { status: 201 });
}
// ✅ request.json() parses JSON body
// ✅ Async method
// ✅ Validates and processesRequest Body (FormData)
export async function POST(request: Request) {
// Parse form data
const formData = await request.formData();
const title = formData.get('title') as string;
const file = formData.get('file') as File;
console.log('Title:', title);
console.log('File:', file.name, file.size);
// Process file...
return Response.json({
success: true,
filename: file.name,
});
}
// ✅ request.formData() for multipart/form-data
// ✅ Access fields with .get()
// ✅ Files are File objectsURL Search Params
export async function GET(request: Request) {
// Parse URL
const { searchParams } = new URL(request.url);
// Get individual params
const query = searchParams.get('q');
const page = searchParams.get('page') || '1';
const limit = searchParams.get('limit') || '10';
console.log('Query:', query);
console.log('Page:', page);
// Use params
const results = await db.posts.findMany({
where: {
title: {
contains: query || '',
},
},
skip: (parseInt(page) - 1) * parseInt(limit),
take: parseInt(limit),
});
return Response.json(results);
}
// GET /api/search?q=nextjs&page=2&limit=20
// ✅ Parse URL with new URL()
// ✅ Access searchParams
// ✅ Default values with ||Request Headers
export async function GET(request: Request) {
// Read headers
const authorization = request.headers.get('authorization');
const userAgent = request.headers.get('user-agent');
const contentType = request.headers.get('content-type');
console.log('Auth:', authorization);
console.log('User-Agent:', userAgent);
// Verify auth
if (!authorization) {
return Response.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Process...
return Response.json({ success: true });
}
// ✅ request.headers.get(name)
// ✅ Case-insensitive
// ✅ Returns null if not foundSetting Response Properties
Response Headers
export async function GET(request: Request) {
const data = { message: 'Hello' };
return Response.json(data, {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=30',
'X-Custom-Header': 'My Value',
},
});
}
// ✅ Set headers in second argument
// ✅ Cache-Control for caching
// ✅ Custom headers with X- prefixStatus Codes
export async function POST(request: Request) {
const body = await request.json();
// 201 Created
const post = await db.posts.create({ data: body });
return Response.json(post, { status: 201 });
}
export async function GET(request: Request) {
const posts = await db.posts.findMany();
if (posts.length === 0) {
// 404 Not Found
return Response.json(
{ error: 'No posts found' },
{ status: 404 }
);
}
// 200 OK (default)
return Response.json(posts);
}
// Common status codes:
// 200 - OK
// 201 - Created
// 204 - No Content
// 400 - Bad Request
// 401 - Unauthorized
// 403 - Forbidden
// 404 - Not Found
// 500 - Internal Server ErrorCookies
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const body = await request.json();
// Create response
const response = NextResponse.json({
success: true,
user: body.username,
});
// Set cookie
response.cookies.set('session', 'session-token-123', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 1 week
});
return response;
}
export async function GET(request: Request) {
// Read cookie from request
const session = request.headers.get('cookie')
?.split('; ')
.find(c => c.startsWith('session='))
?.split('=')[1];
if (!session) {
return Response.json(
{ error: 'No session' },
{ status: 401 }
);
}
return Response.json({ session });
}
// ✅ NextResponse for cookie helpers
// ✅ httpOnly for security
// ✅ secure in productionPractical Route Handler Examples
Example 1: Simple REST API
import { NextResponse } from 'next/server';
// In-memory storage (use database in production)
let todos = [
{ id: '1', text: 'Learn Next.js', completed: false },
{ id: '2', text: 'Build an app', completed: false },
];
// GET - List all todos
export async function GET(request: Request) {
return NextResponse.json(todos);
}
// POST - Create new todo
export async function POST(request: Request) {
const body = await request.json();
const newTodo = {
id: Date.now().toString(),
text: body.text,
completed: false,
};
todos.push(newTodo);
return NextResponse.json(newTodo, { status: 201 });
}
// DELETE - Delete all todos
export async function DELETE(request: Request) {
todos = [];
return NextResponse.json({ success: true });
}
// ✅ Complete CRUD API
// ✅ GET, POST, DELETE methods
// ✅ RESTful patternsExample 2: External API Proxy
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const city = searchParams.get('city');
if (!city) {
return Response.json(
{ error: 'City parameter required' },
{ status: 400 }
);
}
try {
// Fetch from external API
const apiKey = process.env.WEATHER_API_KEY;
const response = await fetch(
`https://api.weather.com/v1/current?city=${city}&key=${apiKey}`
);
if (!response.ok) {
throw new Error('Weather API failed');
}
const data = await response.json();
// Return proxied data
return Response.json({
city,
temperature: data.temp,
conditions: data.conditions,
});
} catch (error) {
return Response.json(
{ error: 'Failed to fetch weather' },
{ status: 500 }
);
}
}
// GET /api/weather?city=London
// ✅ Proxy external API
// ✅ Hide API key from client
// ✅ Transform response
// ✅ Error handlingExample 3: Webhook Receiver
import { headers } from 'next/headers';
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get('stripe-signature');
if (!signature) {
return Response.json(
{ error: 'No signature' },
{ status: 400 }
);
}
try {
// Verify webhook signature
const event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
// Handle event
switch (event.type) {
case 'payment_intent.succeeded':
const paymentIntent = event.data.object;
console.log('Payment succeeded:', paymentIntent.id);
// Update database...
break;
case 'payment_intent.failed':
console.log('Payment failed');
// Handle failure...
break;
}
return Response.json({ received: true });
} catch (error) {
console.error('Webhook error:', error);
return Response.json(
{ error: 'Webhook verification failed' },
{ status: 400 }
);
}
}
// ✅ Webhook endpoint
// ✅ Signature verification
// ✅ Event processing
// ✅ SecureRoute Handlers Structure
Organization of API routes in the app directory
Select a file or folder to see details
Route Handler Best Practices
1. Use Proper HTTP Methods
// ✅ GOOD: RESTful methods
export async function GET() { /* Get resources */ }
export async function POST() { /* Create resource */ }
export async function PUT() { /* Full update */ }
export async function PATCH() { /* Partial update */ }
export async function DELETE() { /* Delete resource */ }
// ❌ BAD: POST for everything
export async function POST(request: Request) {
const { action } = await request.json();
if (action === 'get') { /* ... */ }
if (action === 'delete') { /* ... */ }
}
// Use proper HTTP semantics2. Return Appropriate Status Codes
// ✅ GOOD: Meaningful status codes
// Created
return Response.json(resource, { status: 201 });
// Not found
return Response.json({ error: 'Not found' }, { status: 404 });
// Bad request
return Response.json({ error: 'Invalid data' }, { status: 400 });
// ❌ BAD: Always 200
return Response.json({ error: 'Not found' }); // Still 200!
// Status codes communicate meaning3. Validate Input
// ✅ GOOD: Validate before processing
export async function POST(request: Request) {
const body = await request.json();
if (!body.email || !body.password) {
return Response.json(
{ error: 'Email and password required' },
{ status: 400 }
);
}
// Process...
}
// Even better: Use Zod
import { z } from 'zod';
const schema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
export async function POST(request: Request) {
const body = await request.json();
const result = schema.safeParse(body);
if (!result.success) {
return Response.json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
// Use validated result.data
}4. Handle Errors Gracefully
// ✅ GOOD: Try-catch with error handling
export async function GET(request: Request) {
try {
const data = await db.posts.findMany();
return Response.json(data);
} catch (error) {
console.error('Database error:', error);
return Response.json(
{ error: 'Failed to fetch posts' },
{ status: 500 }
);
}
}
// Don't expose internal errors to clients5. Use Environment Variables for Secrets
// ✅ GOOD: Environment variables
const apiKey = process.env.API_KEY;
const dbUrl = process.env.DATABASE_URL;
// ❌ BAD: Hard-coded secrets
const apiKey = 'sk_live_abc123'; // NEVER!
// Never commit secrets to GitKey Takeaways
- route.ts file - creates Route Handlers
- Export HTTP methods - GET, POST, PUT, PATCH, DELETE
- Web Request/Response - standard APIs
- Response.json() - return JSON responses
- Cannot coexist with page.tsx - either page or API
- Read with request.json() - parse request body
- Status codes matter - use appropriate codes
- NextResponse - for cookies and advanced features
What's Next?
You've mastered Route Handler basics! Next, we'll dive deep into GET and POST Request Handlers—building complete CRUD APIs, handling different request types, working with query parameters, and implementing RESTful patterns. You'll build production-ready API endpoints!
We'll cover GET request patterns, POST request handling, request body parsing, query parameter processing, and complete API examples.
🔒 Security First
Always validate input, use appropriate authentication, never expose secrets, handle errors gracefully, and use HTTPS in production. Route Handlers are publicly accessible by default—protect sensitive endpoints!