Next.js API Routes: The Ultimate Guide

Learn how to build robust, secure, and efficient API endpoints in Next.js. This comprehensive guide will walk you through the critical best practices for creating robust, secure, and efficient API endpoints in Next.js.

While Next.js makes it incredibly easy to create API routes and server-side functionality, building production-ready APIs requires more than just basic implementation.

This comprehensive guide will walk you through the critical best practices for creating robust, secure, and efficient API endpoints in Next.js.

Understanding Next.js API Development: The Key Concepts

Next.js has revolutionized web development by providing a seamless full-stack experience. With the App Router, developers can now create sophisticated backend functionality directly within their Next.js applications.

However, this power comes with responsibility. To build truly enterprise-grade applications, you need to focus on several key areas:

  1. Security: Protecting your endpoints from unauthorized access
  2. Data Validation: Ensuring data integrity and preventing malicious inputs
  3. Error Management: Handling unexpected scenarios gracefully
  4. Performance: Writing performant asynchronous code and implementing caching strategies
  5. Cross-Origin Resource Sharing (CORS): Managing cross-domain requests
  6. Observability: Implementing comprehensive logging
  7. Modern Data Mutations: Leveraging Server Actions

Important Note: This guide is specifically tailored for Next.js App Router. If you're using the Pages Router, some implementations may differ.

Server Side Code execution in Next.js

In more traditional Server-side frameworks, we're used to running server-side code in a dedicated REST API that the client side can consume.

In Next.js, the paradigm is a bit different, as we have multiple execution contexts:

  1. Server Components: these components are rendered server-side. We can call the DB or other services straight from them, without a need to setup an API endpoint.
  2. Route Handlers: these are more commonly known as API endpoints. They are executed server-side, and can only be called using the Fetch API / HTTP requests.
  3. Server Actions: these are special functions that are executed server-side, but can be called from React Components client-side just like any other Javascript function. They are a higher-level abstraction than Route Handlers.
  4. Middleware: Next.js provides a single middleware function, and can be used to run code before each request is processed.

When we talk about "API endpoints", we're referring to the Route Handlers and Server Actions.

Deep dive into Next.js API Route Handlers

Before we dive into the best practices for API Route Handlers, let's understand how to write and use Next.js API Route Handlers.

Route Handlers are Next.js's powerful way to create API endpoints using modern Web APIs. This guide will teach you everything you need to know about creating and managing API routes in your Next.js application.

Understanding Route Handler Basics

Route Handlers live in your app directory and use the route.ts (or route.js) file.

Here's how your project structure might look:

app/
├── api/
│ ├── users/
│ │ └── route.ts # Handles /api/users
│ ├── posts/
│ │ ├── route.ts # Handles /api/posts
│ │ └── [id]/
│ │ └── route.ts # Handles /api/posts/123
│ └── route.ts # Handles /api
└── page.tsx

Did you notice the route.ts file? These special files are called Route Handlers and we can use them to handle HTTP requests towards our API endpoints.

For example, the app/api/users/route.ts file will handle all requests towards the /api/users endpoint.

HTTP Methods in Route Handlers

Route Handlers support all standard HTTP methods. Here's how to implement them:

// app/api/users/route.ts
// Handle GET requests
export async function GET(request: Request) {
return Response.json({ message: 'Getting users' })
}
// Handle POST requests
export async function POST(request: Request) {
const data = await request.json()
return Response.json({ message: 'Creating user', data })
}
// Handle PUT requests
export async function PUT(request: Request) {
const data = await request.json()
return Response.json({ message: 'Updating user', data })
}
// Handle DELETE requests
export async function DELETE(request: Request) {
return Response.json({ message: 'Deleting user' })
}
// Handle PATCH requests
export async function PATCH(request: Request) {
const data = await request.json()
return Response.json({ message: 'Patching user', data })
}
// Handle OPTIONS requests
export async function OPTIONS(request: Request) {
return new Response(null, {
status: 204,
headers: {
'Allow': 'GET, POST, PUT, DELETE, PATCH, OPTIONS'
}
})
}

Basically, every HTTP verb can be exported as a function in your API Route Handler. This allows you to handle different HTTP methods in a single file.

Working with Next.js API Requests

In the section below, we look at some practical examples to help you understand how to work with Next.js API Requests.

The "NextRequest" Object

Next.js extends the standard Request object with NextRequest, providing additional functionality:

import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// Get the complete URL
const url = request.url;
// Get the request headers
const userAgent = request.headers.get('user-agent');
// Get cookies
const cookieValue = request.cookies.get('my-cookie');
return NextResponse.json({
url,
userAgent,
cookieValue,
});
}

It also extends the standard Response object with NextResponse.

Query Parameters

Next.js makes it easy to work with query parameters:

// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// For URL: /api/search?query=hello&page=1
const searchParams = request.nextUrl.searchParams;
// Get single parameter
const query = searchParams.get('query'); // "hello"
const page = searchParams.get('page'); // "1"
// Get all parameters
const allParams = Object.fromEntries(searchParams.entries());
// Check if parameter exists
const hasFilter = searchParams.has('filter');
// Get multiple values for same parameter
// URL: /api/search?tag=news&tag=tech
const tags = searchParams.getAll('tag'); // ["news", "tech"]
return NextResponse.json({
query,
page,
allParams,
hasFilter,
tags
});
}

Dynamic Route Segments

Handle dynamic path segments using route parameters:

// app/api/posts/[id]/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
// URL: /api/posts/123
const postId = params.id; // "123"
return NextResponse.json({
postId,
message: `Fetching post ${postId}`
});
}
// Multiple dynamic segments
// app/api/posts/[categoryId]/[postId]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { categoryId: string; postId: string } }
) {
const { categoryId, postId } = params;
return NextResponse.json({
categoryId,
postId,
message: `Fetching post ${postId} from category ${categoryId}`
});
}

In the above example, we use the params object to extract the categoryId and postId from the URL path.

Handling Headers

Next.js provides utilities for working with headers:

// app/api/headers/route.ts
import { headers } from 'next/headers';
import { NextRequest } from 'next/server';
export async function GET(request: NextRequest) {
// Reading headers
const headersList = headers();
const contentType = headersList.get('content-type');
const authorization = headersList.get('authorization');
// Setting response headers
return new Response('Hello', {
headers: {
'Content-Type': 'text/plain',
'X-Custom-Header': 'custom value',
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
}

💡 Remember: the headers() object does not allow you to set the headers directly. Instead, return an object with the headers you want to set to the headers property of the NextResponse object.

Working with Cookies

Next.js provides a powerful cookies API:

// app/api/cookies/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
// Reading cookies
const cookieStore = cookies();
// Get a specific cookie
const theme = cookieStore.get('theme');
// Get all cookies
const allCookies = cookieStore.getAll();;
// Check if cookie exists
const hasToken = cookieStore.has('token')
return NextResponse.json({
theme: theme?.value,
allCookies,
hasToken
});
}
export async function POST(request: NextRequest) {
// Setting cookies
cookies().set('theme', 'dark', {
path: '/', // Cookie path
secure: true, // HTTPS only
httpOnly: true, // Not accessible via JavaScript
sameSite: 'strict', // CSRF protection
maxAge: 60 * 60 * 24 // 1 day
});
// Deleting cookies
cookies().delete('old-cookie');
return NextResponse.json({ success: true });
}

Request Body

Handle different types of request bodies:

// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function POST(request: NextRequest) {
// JSON data
if (request.headers.get('content-type')?.includes('application/json')) {
const jsonData = await request.json();
return NextResponse.json({ type: 'json', data: jsonData });
}
// Form data
if (request.headers.get('content-type')?.includes('multipart/form-data')) {
const formData = await request.formData();
const name = formData.get('name');
const file = formData.get('file');
return NextResponse.json({ type: 'form', name, file });
}
// Text data
if (request.headers.get('content-type')?.includes('text/plain')) {
const text = await request.text();
return NextResponse.json({ type: 'text', data: text });
}
// ArrayBuffer
const buffer = await request.arrayBuffer();
// Blob
const blob = await request.blob();
return NextResponse.json({ message: 'Unsupported content type' }, { status: 415 });
}

Response Types

Route Handlers support various response types:

// app/api/responses/route.ts
export async function GET(request: NextRequest) {
// JSON Response
return NextResponse.json({ message: 'Hello' });
// Text Response
return new Response('Hello', {
headers: { 'Content-Type': 'text/plain' }
});
// HTML Response
return new Response('<h1>Hello</h1>', {
headers: { 'Content-Type': 'text/html' }
});
// Redirect Response
return NextResponse.redirect(new URL('/new-page', request.url));
// Status Codes
return NextResponse.json(
{ error: 'Not Found' },
{ status: 404 }
);
}

Working with Webhooks

Webhooks are a common pattern in API development. In practice, webhooks are HTTP requests sent to your API from a third-party service (such as Zapier, Slack, or Stripe) to notify you of specific events.

To handle webhooks, you can use the NextRequest object to access the request body and headers:

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.text();
const headers = request.headers;
// handle Stripe Event
return NextResponse.json(
{ message: 'Webhook received' },
{ status: 200 }
);
}

Commonly, webhooks must be verified to ensure they are coming from a trusted source. To do so, we'd normally need to retireve the raw body from the request and compare it with the signature sent by Stripe. We can do so by simply using the text() method from the NextRequest object.

Error Handling

To return errors in your API Route Handlers, you can use the NextResponse object. This object provides a convenient way to set the status code and headers for your responses.

// app/api/protected/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
try {
// Check authorization
const token = request.headers.get('authorization');
if (!token) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Validate parameters
const { searchParams } = request.nextUrl;
const id = searchParams.get('id');
if (!id) {
return NextResponse.json(
{ error: 'Missing id parameter' },
{ status: 400 }
)
}
// Process request
const data = await fetchData(id);
if (!data) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
)
}
return NextResponse.json(data);
} catch (error) {
console.error('Request failed:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
)
}
}

Securing API Endpoints

Security is a broad word, so it's important to specify what we mean by that.

There are several layers of security that we can implement to protect our API endpoints, such as:

  1. CSRF Protection: Preventing Cross-Site Request Forgery (CSRF) attacks
  2. Authentication: Verifying the identity of the user making the request
  3. Authorization: Restricting access to certain resources or actions
  4. Payload Validation: Ensuring that the data being sent to the API is valid and meets the required criteria

CSRF Protection

To implement CSRF protection, you can use the @edge-csrf/nextjs package. This package provides a middleware that automatically generates and validates CSRF tokens for your API routes.

Simply install the package and change your middleware to use the createCsrfMiddleware function:

import { createCsrfMiddleware } from '@edge-csrf/nextjs';
const csrfMiddleware = createCsrfMiddleware({
cookie: {
secure: process.env.NODE_ENV === 'production',
},
});
export const middleware = csrfMiddleware;

Now, all the mutation request will be protected against CSRF attacks (e.g. POST, PUT, DELETE, PATCH).

The client-side will need to include the CSRF token in the request headers:

  1. By adding the X-CSRF-Token header to the request.
  2. By using a csrf_token parameter in the request body.

💡 You may want to exclude some paths (such as "/api") so that external API calls are not affected by the CSRF protection.

Payload Validation

Data validation is crucial for maintaining the quality and security of your application. By implementing robust validation, you can prevent malformed data from entering your system and protect against potential security vulnerabilities.

import { z } from 'zod'
import { NextResponse } from 'next/server'
const UserSchema = z.object({
name: z.string().min(2, { message: "Name must be at least 2 characters" }),
email: z.string().email({ message: "Invalid email address" }),
age: z.number().min(18, { message: "Must be at least 18 years old" })
})
export async function POST(request: Request) {
const body = await request.json()
try {
const validatedData = UserSchema.parse(body)
// Process valid data
return NextResponse.json(validatedData, { status: 201 })
} catch (error) {
if (error instanceof z.ZodError) {
return NextResponse.json({
error: 'Validation Failed',
details: error.errors
}, { status: 400 })
}
}
}

Why does validating your API Route Handlers matter?

  • Prevents invalid data from entering your system
  • Provides clear, helpful error messages
  • Reduces the risk of data-related bugs and security issues

Authentication and Authorization

These are highly dependent on the specific authentication and authorization mechanisms you're using.

Assuming you have a requireUser function that checks if the user is authenticated, you can use it in your API Route Handlers:

// app/api/protected/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { requireUser } from '@/lib/auth';
export async function GET(request: NextRequest) {
try {
// Check authorization
const user = await requireUser(request);
if (!user) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
);
}
// Process request
const data = await fetchData(user.id);
if (!data) {
return NextResponse.json(
{ error: 'Not found' },
{ status: 404 }
);
}
return NextResponse.json(data);
} catch (error) {
console.error('Request failed:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

Comprehensive Error Handling

Effective error handling is more than just catching exceptions—it's about providing meaningful feedback and maintaining system stability.

import { NextResponse } from 'next/server'
export function errorHandler(error: Error) {
console.error('API Error:', error)
if (error instanceof ValidationError) {
return NextResponse.json({
error: 'Validation Error',
details: error.details
}, { status: 400 })
}
if (error instanceof NotFoundError) {
return NextResponse.json({
error: 'Resource Not Found'
}, { status: 404 })
}
// Catch-all for unexpected errors
return NextResponse.json({
error: 'Internal Server Error'
}, { status: 500 })
}

A good API Handler will return a response that the client can understand and handle, not "Internal Server Error" (unless you have no other choice). No one wants to see a cryptic error message.

At the same time, we need to ease the burden on the client-side code to handle errors. This is why we use the try/catch block in our API Route Handlers.

Why does error handling matter?

  • Improves debugging capabilities
  • Provides clear feedback to clients
  • Prevents sensitive information leakage
  • Maintains application stability
  • Makes client-side code easier to write and maintain

API Logging and Observability

Implementing comprehensive logging is crucial for monitoring and debugging your API endpoints. We'll use Pino, a low-overhead JSON logger that's perfect for Next.js applications.

Setting Up Pino Logger

First, install the required dependencies:

npm install pino pino-pretty

Create a base logger configuration:

// lib/logger.ts
import pino from 'pino';
const transport = pino.transport({
target: 'pino-pretty',
options: {
colorize: process.env.NODE_ENV === 'development',
translateTime: 'UTC:yyyy-mm-dd HH:MM:ss.l o',
ignore: 'pid,hostname'
}
});
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
// Base fields for all logs
base: {
env: process.env.NODE_ENV,
service: process.env.SERVICE_NAME || 'api'
}
}, transport);

Request Logging in Route Handlers

Here's how to implement request logging in your API endpoints:

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
export async function GET(request: NextRequest) {
const requestLog = logger.child({
method: request.method,
url: request.url,
headers: {
'user-agent': request.headers.get('user-agent'),
'accept': request.headers.get('accept')
}
});
const startTime = performance.now();
try {
requestLog.info('incoming request');
const users = await fetchUsers();
const duration = Math.round(performance.now() - startTime);
requestLog.info({ duration }, 'request completed');
return NextResponse.json(users);
} catch (error) {
const duration = Math.round(performance.now() - startTime);
requestLog.error({
error: {
message: error instanceof Error ? error.message : 'Unknown error',
stack: error instanceof Error ? error.stack : undefined
},
duration
}, 'request failed');
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

Adding Request Context

To track requests across your application, implement request context using middleware:

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const requestId = crypto.randomUUID();
const response = NextResponse.next();
response.headers.set('x-request-id', requestId);
return response;
}
export const config = {
matcher: '/api/:path*'
};

Create a request-scoped logger:

// lib/logger/request.ts
import { headers } from 'next/headers';
import { logger } from './logger';
export function getRequestLogger() {
const headersList = headers();
const requestId = headersList.get('x-request-id');
const userId = headersList.get('x-user-id');
return logger.child({
requestId: requestId ?? 'unknown',
userId: userId ?? undefined
});
}

Use the request logger in your Route Handlers:

// app/api/data/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getRequestLogger } from '@/lib/logger/request';
export async function GET(request: NextRequest) {
const log = getRequestLogger();
const startTime = performance.now();
try {
// Log request details
log.info({
query: Object.fromEntries(request.nextUrl.searchParams)
}, 'processing request');
const data = await fetchData();
// Log successful completion
log.info({
duration: Math.round(performance.now() - startTime)
}, 'request successful');
return NextResponse.json(data);
} catch (error) {
// Log error with details
log.error({
error: {
type: error instanceof Error ? error.constructor.name : 'Unknown',
message: error instanceof Error ? error.message : 'Unknown error',
stack: process.env.NODE_ENV === 'development' && error instanceof Error ?
error.stack : undefined
},
duration: Math.round(performance.now() - startTime)
}, 'request failed');
return NextResponse.json(
{ error: 'Internal Server Error' },
{ status: 500 }
);
}
}

Best Practices when Logging API Requests

Structured Logging

Instead of logging raw data, it's best to structure your logs for easy querying. This makes it easier to analyze and debug issues.

// ❌ Bad - Unstructured log
log.info(`Processing request for user ${userId}`);
// ✅ Good - Structured log
log.info({ userId, action: 'process' }, 'processing request');

Performance Logging

Sometimes, you might need to log performance metrics to track the performance of your API requests. This can be ideal especially for APIs that handle large volumes of data that you want to monitor.

// ❌ Bad - Missing performance metrics
log.info('request completed');
// ✅ Good - Including duration
log.info({ duration: performance.now() - startTime }, 'request completed');

*Error Logging

When an error occurs, do log the error details to help identify and fix the issue. This includes the error type, message, and stack trace.

// ❌ Bad - Logging raw error
log.error(error);
// ✅ Good - Structured error logging
log.error({
error: {
type: error.constructor.name,
message: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
}
}, 'operation failed');

Context Preservation

When logging errors or other important information, do preserve the context of the request. This ensures that the information is still relevant and useful when debugging issues.

// ❌ Bad - New logger instance
logger.info({ action: 'save' });
// ✅ Good - Using child logger with context
const requestLog = logger.child({ requestId, userId });
requestLog.info({ action: 'save' });

Remember:

  • Never log sensitive information (tokens, passwords)
  • Use appropriate log levels (info, warn, error)
  • Include request IDs for tracing
  • Structure your logs for easy querying
  • Consider log volume and performance impact. They get pricey!

Next.js API Route Handlers or Server Actions?

This is a very common question! You're in luck, we have a great tutorial about API Route Handlers vs Server Actions that will help you decide which one to use.

TLDR; Use Server Actions for internal mutations, otherwise use API Route Handlers.

In conclusion...

Next.js API handlers are fairly simple to use, and their DX is great.

However, just like any other framework, there are a few best practices to keep in mind when working with APIs in Next.js.

About Makerkit, the Next.js SaaS Starter Kit for production-ready SaaS applications

Looking for a production-ready Next.js SaaS Boilerplate? MakerKit provides a comprehensive boilerplate that extends beyond basic Next.js and Supabase integration, offering everything you need to launch your SaaS product faster.