Next.js API routes enable full-stack applications. Here's how to build them effectively.
Basic Route Handler#
1// app/api/users/route.ts (App Router)
2import { NextRequest, NextResponse } from 'next/server';
3
4export async function GET(request: NextRequest) {
5 const users = await db.user.findMany();
6
7 return NextResponse.json(users);
8}
9
10export async function POST(request: NextRequest) {
11 const body = await request.json();
12
13 const user = await db.user.create({
14 data: body,
15 });
16
17 return NextResponse.json(user, { status: 201 });
18}
19
20// pages/api/users.ts (Pages Router)
21import type { NextApiRequest, NextApiResponse } from 'next';
22
23export default async function handler(
24 req: NextApiRequest,
25 res: NextApiResponse
26) {
27 if (req.method === 'GET') {
28 const users = await db.user.findMany();
29 return res.json(users);
30 }
31
32 if (req.method === 'POST') {
33 const user = await db.user.create({ data: req.body });
34 return res.status(201).json(user);
35 }
36
37 res.setHeader('Allow', ['GET', 'POST']);
38 res.status(405).end(`Method ${req.method} Not Allowed`);
39}Dynamic Routes#
1// app/api/users/[id]/route.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4interface RouteParams {
5 params: { id: string };
6}
7
8export async function GET(
9 request: NextRequest,
10 { params }: RouteParams
11) {
12 const user = await db.user.findUnique({
13 where: { id: params.id },
14 });
15
16 if (!user) {
17 return NextResponse.json(
18 { error: 'User not found' },
19 { status: 404 }
20 );
21 }
22
23 return NextResponse.json(user);
24}
25
26export async function PUT(
27 request: NextRequest,
28 { params }: RouteParams
29) {
30 const body = await request.json();
31
32 const user = await db.user.update({
33 where: { id: params.id },
34 data: body,
35 });
36
37 return NextResponse.json(user);
38}
39
40export async function DELETE(
41 request: NextRequest,
42 { params }: RouteParams
43) {
44 await db.user.delete({
45 where: { id: params.id },
46 });
47
48 return new NextResponse(null, { status: 204 });
49}Request Handling#
1// Query parameters
2export async function GET(request: NextRequest) {
3 const { searchParams } = new URL(request.url);
4 const page = parseInt(searchParams.get('page') || '1');
5 const limit = parseInt(searchParams.get('limit') || '10');
6 const search = searchParams.get('search') || '';
7
8 const users = await db.user.findMany({
9 where: {
10 name: { contains: search, mode: 'insensitive' },
11 },
12 skip: (page - 1) * limit,
13 take: limit,
14 });
15
16 const total = await db.user.count({
17 where: {
18 name: { contains: search, mode: 'insensitive' },
19 },
20 });
21
22 return NextResponse.json({
23 data: users,
24 pagination: {
25 page,
26 limit,
27 total,
28 pages: Math.ceil(total / limit),
29 },
30 });
31}
32
33// Headers and cookies
34export async function GET(request: NextRequest) {
35 const authHeader = request.headers.get('authorization');
36 const userAgent = request.headers.get('user-agent');
37
38 const token = request.cookies.get('token')?.value;
39
40 // Set response headers
41 return NextResponse.json(
42 { data: 'example' },
43 {
44 headers: {
45 'X-Custom-Header': 'value',
46 'Cache-Control': 'max-age=3600',
47 },
48 }
49 );
50}
51
52// Set cookies in response
53export async function POST(request: NextRequest) {
54 const response = NextResponse.json({ success: true });
55
56 response.cookies.set('token', 'jwt-token-here', {
57 httpOnly: true,
58 secure: process.env.NODE_ENV === 'production',
59 sameSite: 'strict',
60 maxAge: 60 * 60 * 24 * 7, // 1 week
61 });
62
63 return response;
64}Validation#
1import { z } from 'zod';
2import { NextRequest, NextResponse } from 'next/server';
3
4const createUserSchema = z.object({
5 email: z.string().email(),
6 name: z.string().min(2).max(100),
7 password: z.string().min(8),
8});
9
10export async function POST(request: NextRequest) {
11 const body = await request.json();
12
13 const result = createUserSchema.safeParse(body);
14
15 if (!result.success) {
16 return NextResponse.json(
17 {
18 error: 'Validation failed',
19 details: result.error.flatten().fieldErrors,
20 },
21 { status: 400 }
22 );
23 }
24
25 const { email, name, password } = result.data;
26
27 // Check for existing user
28 const existing = await db.user.findUnique({ where: { email } });
29
30 if (existing) {
31 return NextResponse.json(
32 { error: 'Email already registered' },
33 { status: 409 }
34 );
35 }
36
37 const hashedPassword = await bcrypt.hash(password, 10);
38
39 const user = await db.user.create({
40 data: {
41 email,
42 name,
43 password: hashedPassword,
44 },
45 });
46
47 return NextResponse.json(
48 { id: user.id, email: user.email, name: user.name },
49 { status: 201 }
50 );
51}
52
53// Reusable validation helper
54function validateBody<T>(schema: z.Schema<T>) {
55 return async (request: NextRequest) => {
56 try {
57 const body = await request.json();
58 return schema.parse(body);
59 } catch (error) {
60 if (error instanceof z.ZodError) {
61 throw new ValidationError(error.flatten().fieldErrors);
62 }
63 throw error;
64 }
65 };
66}Error Handling#
1// lib/api-error.ts
2export class ApiError extends Error {
3 constructor(
4 public statusCode: number,
5 message: string,
6 public code?: string
7 ) {
8 super(message);
9 }
10}
11
12export function handleApiError(error: unknown) {
13 console.error('API Error:', error);
14
15 if (error instanceof ApiError) {
16 return NextResponse.json(
17 { error: error.message, code: error.code },
18 { status: error.statusCode }
19 );
20 }
21
22 if (error instanceof z.ZodError) {
23 return NextResponse.json(
24 { error: 'Validation failed', details: error.flatten() },
25 { status: 400 }
26 );
27 }
28
29 // Prisma errors
30 if (error instanceof Prisma.PrismaClientKnownRequestError) {
31 if (error.code === 'P2002') {
32 return NextResponse.json(
33 { error: 'Resource already exists' },
34 { status: 409 }
35 );
36 }
37 if (error.code === 'P2025') {
38 return NextResponse.json(
39 { error: 'Resource not found' },
40 { status: 404 }
41 );
42 }
43 }
44
45 return NextResponse.json(
46 { error: 'Internal server error' },
47 { status: 500 }
48 );
49}
50
51// Usage in route
52export async function POST(request: NextRequest) {
53 try {
54 const body = await request.json();
55 const user = await createUser(body);
56 return NextResponse.json(user, { status: 201 });
57 } catch (error) {
58 return handleApiError(error);
59 }
60}Authentication Middleware#
1// lib/auth.ts
2import { NextRequest, NextResponse } from 'next/server';
3import jwt from 'jsonwebtoken';
4
5interface AuthUser {
6 id: string;
7 email: string;
8 role: string;
9}
10
11export function withAuth(
12 handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse>
13) {
14 return async (request: NextRequest) => {
15 const token = request.headers.get('authorization')?.replace('Bearer ', '');
16
17 if (!token) {
18 return NextResponse.json(
19 { error: 'Authentication required' },
20 { status: 401 }
21 );
22 }
23
24 try {
25 const decoded = jwt.verify(token, process.env.JWT_SECRET!) as AuthUser;
26 return handler(request, decoded);
27 } catch {
28 return NextResponse.json(
29 { error: 'Invalid token' },
30 { status: 401 }
31 );
32 }
33 };
34}
35
36// Role-based auth
37export function withRole(roles: string[]) {
38 return (
39 handler: (request: NextRequest, user: AuthUser) => Promise<NextResponse>
40 ) => {
41 return withAuth(async (request, user) => {
42 if (!roles.includes(user.role)) {
43 return NextResponse.json(
44 { error: 'Insufficient permissions' },
45 { status: 403 }
46 );
47 }
48 return handler(request, user);
49 });
50 };
51}
52
53// Usage
54// app/api/admin/users/route.ts
55export const GET = withRole(['admin'])(async (request, user) => {
56 const users = await db.user.findMany();
57 return NextResponse.json(users);
58});Rate Limiting#
1// lib/rate-limit.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4const rateLimit = new Map<string, { count: number; resetTime: number }>();
5
6export function withRateLimit(
7 limit: number,
8 windowMs: number
9) {
10 return (
11 handler: (request: NextRequest) => Promise<NextResponse>
12 ) => {
13 return async (request: NextRequest) => {
14 const ip = request.ip || 'anonymous';
15 const now = Date.now();
16
17 const record = rateLimit.get(ip);
18
19 if (!record || now > record.resetTime) {
20 rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
21 } else if (record.count >= limit) {
22 return NextResponse.json(
23 { error: 'Too many requests' },
24 {
25 status: 429,
26 headers: {
27 'Retry-After': String(Math.ceil((record.resetTime - now) / 1000)),
28 },
29 }
30 );
31 } else {
32 record.count++;
33 }
34
35 return handler(request);
36 };
37 };
38}
39
40// Usage
41export const POST = withRateLimit(10, 60000)(async (request) => {
42 // Handle request
43 return NextResponse.json({ success: true });
44});File Uploads#
1// app/api/upload/route.ts
2import { writeFile } from 'fs/promises';
3import { NextRequest, NextResponse } from 'next/server';
4import path from 'path';
5
6export async function POST(request: NextRequest) {
7 const formData = await request.formData();
8 const file = formData.get('file') as File;
9
10 if (!file) {
11 return NextResponse.json(
12 { error: 'No file uploaded' },
13 { status: 400 }
14 );
15 }
16
17 // Validate file type
18 const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
19 if (!allowedTypes.includes(file.type)) {
20 return NextResponse.json(
21 { error: 'Invalid file type' },
22 { status: 400 }
23 );
24 }
25
26 // Validate file size (5MB)
27 if (file.size > 5 * 1024 * 1024) {
28 return NextResponse.json(
29 { error: 'File too large' },
30 { status: 400 }
31 );
32 }
33
34 const bytes = await file.arrayBuffer();
35 const buffer = Buffer.from(bytes);
36
37 const filename = `${Date.now()}-${file.name}`;
38 const filepath = path.join(process.cwd(), 'public/uploads', filename);
39
40 await writeFile(filepath, buffer);
41
42 return NextResponse.json({
43 url: `/uploads/${filename}`,
44 });
45}
46
47export const config = {
48 api: {
49 bodyParser: false,
50 },
51};Streaming Responses#
1// app/api/stream/route.ts
2export async function GET() {
3 const encoder = new TextEncoder();
4
5 const stream = new ReadableStream({
6 async start(controller) {
7 for (let i = 0; i < 10; i++) {
8 const data = `data: ${JSON.stringify({ count: i })}\n\n`;
9 controller.enqueue(encoder.encode(data));
10 await new Promise(r => setTimeout(r, 1000));
11 }
12 controller.close();
13 },
14 });
15
16 return new Response(stream, {
17 headers: {
18 'Content-Type': 'text/event-stream',
19 'Cache-Control': 'no-cache',
20 'Connection': 'keep-alive',
21 },
22 });
23}Best Practices#
Structure:
✓ Group related routes in folders
✓ Use consistent naming conventions
✓ Extract shared logic to lib/
✓ Keep handlers focused
Security:
✓ Validate all inputs
✓ Sanitize outputs
✓ Use proper authentication
✓ Implement rate limiting
Performance:
✓ Use edge runtime when possible
✓ Cache responses appropriately
✓ Minimize database queries
✓ Handle errors gracefully
Testing:
✓ Test happy paths
✓ Test error cases
✓ Test validation
✓ Mock external services
Conclusion#
Next.js API routes enable full-stack development with minimal configuration. Use proper validation, error handling, and authentication for production APIs. Structure routes logically and extract shared middleware for maintainability.