Next.js Route Handlers & Server Actions
name: route-handlers description: "Next.js Route Handlers, Server Actions, and Middleware patterns. Covers REST API design with Route Handlers, form handling with Server Actions, middleware for auth/redirects/headers, request/response patterns, and streaming responses. Use this skill when building API endpoints or server-side logic in Next.js." license: MIT metadata: author: Balazs Barta version: "0.1.0"
Overview
This skill covers three core server-side patterns in Next.js:
- •Route Handlers: Build REST APIs and handle HTTP requests
- •Server Actions: Handle forms and mutations on the server
- •Middleware: Intercept and modify requests/responses globally
Quick Lookup: Next.js Documentation
Always check the Next.js llms.txt first for authoritative information on:
- •Route Handlers API reference
- •Server Actions details
- •Middleware configuration and patterns
Route Handlers Basics
Route Handlers are created in app/api/ directory with route.ts or route.js files:
app/
api/
users/
route.ts // /api/users endpoint
users/
[id]/
route.ts // /api/users/[id] dynamic endpoint
Supported HTTP Methods
Route handlers accept these named exports:
export async function GET(request: NextRequest) {}
export async function POST(request: NextRequest) {}
export async function PUT(request: NextRequest) {}
export async function PATCH(request: NextRequest) {}
export async function DELETE(request: NextRequest) {}
export async function HEAD(request: NextRequest) {}
export async function OPTIONS(request: NextRequest) {}
Request & Response Patterns
NextRequest and NextResponse
Route handlers receive NextRequest objects and return NextResponse:
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// Access request properties
const url = request.nextUrl;
const searchParams = url.searchParams;
const pathname = url.pathname;
// Return response
return NextResponse.json({ message: 'Hello' });
}
Accessing Headers and Cookies
import { headers, cookies } from 'next/headers';
export async function GET(request: NextRequest) {
// Get headers
const headersList = await headers();
const authorization = headersList.get('authorization');
// Get/set cookies
const cookieStore = await cookies();
const token = cookieStore.get('sessionToken')?.value;
cookieStore.set('newCookie', 'value', {
httpOnly: true,
secure: true
});
return NextResponse.json({ token });
}
URL Parameters and Query Strings
// Dynamic routes: /api/users/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const userId = params.id;
return NextResponse.json({ userId });
}
// Query strings: /api/users?page=1&limit=10
export async function GET(request: NextRequest) {
const page = request.nextUrl.searchParams.get('page');
const limit = request.nextUrl.searchParams.get('limit');
return NextResponse.json({ page, limit });
}
Parsing Request Bodies
// Parse JSON
export async function POST(request: NextRequest) {
const body = await request.json();
return NextResponse.json({ received: body });
}
// Parse FormData
export async function POST(request: NextRequest) {
const formData = await request.formData();
const name = formData.get('name');
const file = formData.get('file'); // File object
return NextResponse.json({ name });
}
// Parse text/plain
export async function POST(request: NextRequest) {
const text = await request.text();
return NextResponse.json({ text });
}
Server Actions
Server Actions are asynchronous functions that run on the server. Mark them with the "use server" directive.
Basic Server Action (Inline)
'use client';
import { useState } from 'react';
export default function FormComponent() {
async function handleSubmit(formData: FormData) {
'use server';
const name = formData.get('name');
// Database mutation here
}
return (
<form action={handleSubmit}>
<input name="name" required />
<button type="submit">Submit</button>
</form>
);
}
Dedicated Server Action File
// app/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
// Database operation
const post = await db.posts.create({ title });
// Revalidate cache
revalidatePath('/blog');
return post;
}
useActionState Hook
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions';
export default function PostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
{ error: null }
);
return (
<form action={formAction}>
<input name="title" required />
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create'}
</button>
{state.error && <p>{state.error}</p>}
</form>
);
}
useFormStatus Hook
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type="submit" disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
Route Handlers vs Server Actions
| Aspect | Route Handlers | Server Actions |
|---|---|---|
| Use case | REST APIs, webhooks, complex logic | Forms, simple mutations |
| Trigger | HTTP request (fetch, browser nav) | Form submission, client function call |
| Response type | HTTP response (JSON, streams) | Return values, serialized data |
| Best for | External API consumers, webhooks | Internal app forms, mutations |
| Error handling | HTTP status codes | Exceptions, return error objects |
Middleware
Middleware runs before requests reach Route Handlers or pages. Create middleware.ts at your project root:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// Check authentication
const token = request.cookies.get('token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
// Configure which routes use middleware
export const config = {
matcher: [
'/dashboard/:path*',
'/admin/:path*',
'/api/:path*',
],
};
Common Middleware Patterns
Authentication:
const token = request.cookies.get('authToken');
if (!token) {
return NextResponse.redirect(new URL('/login', request.url));
}
Add Headers:
const response = NextResponse.next();
response.headers.set('X-Custom-Header', 'value');
return response;
Internationalization:
const locale = request.headers.get('accept-language')?.split(',')[0] || 'en';
request.nextUrl.pathname = `/${locale}${request.nextUrl.pathname}`;
return NextResponse.rewrite(request.nextUrl);
CORS Handling
Set CORS headers in Route Handlers:
export async function GET(request: NextRequest) {
return NextResponse.json(
{ data: 'Hello' },
{
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST',
'Access-Control-Allow-Headers': 'Content-Type',
},
}
);
}
export async function OPTIONS(request: NextRequest) {
return NextResponse.json(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
'Access-Control-Allow-Headers': 'Content-Type',
},
});
}
Rate Limiting Patterns
Simple in-memory rate limiting:
const requestCounts = new Map<string, { count: number; resetTime: number }>();
function isRateLimited(ip: string, maxRequests: number = 10, windowMs: number = 60000): boolean {
const now = Date.now();
const record = requestCounts.get(ip);
if (!record || now > record.resetTime) {
requestCounts.set(ip, { count: 1, resetTime: now + windowMs });
return false;
}
record.count++;
return record.count > maxRequests;
}
export async function GET(request: NextRequest) {
const ip = request.ip || 'anonymous';
if (isRateLimited(ip)) {
return NextResponse.json(
{ error: 'Too many requests' },
{ status: 429 }
);
}
return NextResponse.json({ data: 'Hello' });
}
Input Validation with Zod
Use Zod for schema validation:
import { z } from 'zod';
const createUserSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number().int().positive().optional(),
});
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const validatedData = createUserSchema.parse(body);
// Safe to use validatedData
const user = await db.users.create(validatedData);
return NextResponse.json(user, { status: 201 });
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json(
{ error: 'Invalid input', details: error.errors },
{ status: 400 }
);
}
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Error Handling Patterns
Standardized error responses:
interface ErrorResponse {
error: string;
code: string;
message?: string;
}
export async function GET(request: NextRequest) {
try {
const data = await fetchData();
return NextResponse.json(data);
} catch (error) {
if (error instanceof NotFoundError) {
return NextResponse.json(
{ error: 'Not found', code: 'NOT_FOUND' } as ErrorResponse,
{ status: 404 }
);
}
if (error instanceof ValidationError) {
return NextResponse.json(
{ error: 'Invalid input', code: 'VALIDATION_ERROR', message: error.message } as ErrorResponse,
{ status: 400 }
);
}
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' } as ErrorResponse,
{ status: 500 }
);
}
}
Streaming Responses
Stream data for long-running operations:
export async function GET(request: NextRequest) {
const stream = new ReadableStream({
async start(controller) {
for (let i = 0; i < 10; i++) {
await new Promise(resolve => setTimeout(resolve, 1000));
controller.enqueue(`data: ${JSON.stringify({ count: i })}\n\n`);
}
controller.close();
},
});
return new NextResponse(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
Resources
For detailed patterns and examples, see:
- •
/skills/route-handlers/references/route-handler-patterns.md- Comprehensive Route Handler patterns - •
/skills/route-handlers/references/server-action-patterns.md- Server Action examples and best practices