There’s something quietly powerful about being able to write backend code without setting up a separate server. No Express boilerplate. No CORS configuration. No deploying and managing two different applications just so a contact form on your marketing page can send an email.
Next.js API routes give you exactly that. They live in the same project as your frontend, share the same deployment pipeline, and run as serverless functions that scale automatically. You create a file under pages/api or app/api, export a function, and suddenly you have an endpoint that can query a database, call an external service, or process a webhook.
I remember the first time I used this in a real project. We had a client who needed a simple newsletter signup form on their marketing site. The previous developer had set up a whole separate Express app just to handle that one form submission—separate repo, separate deployment, separate environment variables. Every time we wanted to tweak the form or add a field, we had to coordinate changes across two codebases. It was tedious. Then someone on the team suggested, “Why don’t we just use an API route?” Within an hour, we had deleted the Express app entirely. The form posted directly to /api/subscribe, the logic lived in a single file, and the client never noticed the difference—except that deployments got simpler and the site got faster. That was the moment I realized API routes weren’t just a convenience; they were a legitimate architectural choice that could save teams from unnecessary complexity.
So if you’re new to this concept or just want a deeper understanding of how to use it well, this guide is for you. We’ll walk through the patterns, the tradeoffs, and the practical techniques that turn a basic route handler into a production-ready backend.
The file-system router you already know
If you’ve used Next.js, you already understand how API routes work. The routing is identical to pages. A file at pages/api/users.js becomes the endpoint /api/users. A file at pages/api/users/[id].js becomes /api/users/123. Dynamic segments, catch-all routes, optional parameters—every routing feature you use for pages applies to API routes.
In the newer App Router, API routes live under app/api. The pattern is the same, but you export named functions for each HTTP method instead of a default handler.
// app/api/users/route.ts
export async function GET(request: Request) {
const users = await db.user.findMany();
return Response.json(users);
}
export async function POST(request: Request) {
const body = await request.json();
const user = await db.user.create({ data: body });
return Response.json(user, { status: 201 });
}
Each exported function handles a specific HTTP method. GET, POST, PUT, PATCH, DELETE—the browser makes a request, Next.js matches the method to the exported function, and your code runs. If a method isn’t exported, Next.js returns a 405 Method Not Allowed automatically. It’s clean, predictable, and honestly kind of fun to work with.
The Pages Router uses a different convention—a single default export that receives req and res objects from Node.js:
// pages/api/users.js
export default async function handler(req, res) {
if (req.method === "GET") {
const users = await db.user.findMany();
res.status(200).json(users);
} else {
res.status(405).json({ error: "Method not allowed" });
}
}
Both approaches work. The App Router version with Request and Response objects is more aligned with web standards and feels more modern. The Pages Router version feels like Express and might be more familiar if you’ve spent years in the Node.js world. If you’re starting a new project, the App Router is the future. But plenty of production applications still use the Pages Router pattern, and it’s perfectly fine. The important thing is understanding the concept: a file under the api directory becomes a serverless endpoint.
When to use API routes instead of server components
Next.js now gives you two ways to run server-side code: API routes and server components. The overlap creates a question that comes up constantly: when should something be an API route, and when should it be a server component?
Let me break it down in a way that makes sense. Server components run during rendering. They fetch data and return JSX. They’re for building pages—fetching the data a page needs to display, then rendering the markup. They don’t expose endpoints that clients can call directly. Think of them as the “server-side rendering” layer.
API routes, on the other hand, are endpoints. They return JSON, not JSX. They’re for form submissions, webhooks, mobile app backends, and any situation where a client needs to send data to your server outside of a page navigation.
A practical rule of thumb: if a browser page needs data to render, fetch it in a server component. If a form needs to submit data, create an API route. If a mobile app or third-party service needs to interact with your backend, create an API route.
// Server Component — fetches data for page rendering
export default async function UsersPage() {
const users = await db.user.findMany();
return <UserList users={users} />;
}
// API Route — handles form submissions
export async function POST(request: Request) {
const { name, email } = await request.json();
await db.user.create({ data: { name, email } });
return Response.json({ success: true }, { status: 201 });
}
Server components can’t handle POST requests. API routes can. That’s the fundamental distinction. If you’ve been working with React Server Components in Next.js, you already know that they’re designed for data fetching during rendering. API routes handle everything else. Once you internalize that separation, your architecture gets a lot cleaner.
Handling request bodies, query parameters, and headers
API routes give you full access to the incoming request. The Request object in the App Router provides methods for reading the body, parsing form data, and accessing headers. Query parameters come from the URL.
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const page = searchParams.get('page') || '1';
const limit = searchParams.get('limit') || '10';
const sort = searchParams.get('sort') || 'createdAt';
const users = await db.user.findMany({
skip: (Number(page) - 1) * Number(limit),
take: Number(limit),
orderBy: { [sort]: 'desc' },
});
return Response.json(users);
}
For POST requests with JSON bodies, request.json() parses the incoming data. For form submissions, request.formData() handles multipart form data, including file uploads. The web standard APIs are fully supported, which means your API route code is portable—it would work in any runtime that supports the Fetch API. That’s a big deal because it means you can reuse your logic in edge functions, service workers, or even other frameworks.
Dynamic route segments become parameters you can access from the function arguments:
export async function GET(
request: Request,
{ params }: { params: { id: string } }
) {
const user = await db.user.findUnique({
where: { id: params.id },
});
if (!user) {
return Response.json({ error: 'User not found' }, { status: 404 });
}
return Response.json(user);
}
The params object contains the dynamic segments from the URL. TypeScript infers the types if you define them, giving you autocomplete and catching typos. It’s a small detail, but it makes development smoother.
Error handling that doesn’t crash your endpoint
API routes need error handling. An unhandled rejection in an API route crashes the function—Next.js catches it and returns a 500 status, but you lose control over the error response. Adding structured error handling gives you consistent error responses and better debugging.
The pattern is straightforward. Wrap your handler logic in a try-catch, log the error, and return an appropriate response.
export async function POST(request: Request) {
try {
const body = await request.json();
const user = await db.user.create({ data: body });
return Response.json(user, { status: 201 });
} catch (error) {
console.error('Failed to create user:', error);
if (error instanceof Prisma.PrismaClientKnownRequestError) {
return Response.json(
{ error: 'A user with that email already exists' },
{ status: 409 }
);
}
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
For a more thorough approach, extract the error handling into a reusable wrapper. This keeps your route handlers clean and ensures consistent error responses across all endpoints.
function withErrorHandler(handler: Function) {
return async (request: Request, context: any) => {
try {
return await handler(request, context);
} catch (error) {
console.error(error);
return Response.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
};
}
export const GET = withErrorHandler(async (request: Request) => {
const users = await db.user.findMany();
return Response.json(users);
});
If you’ve read our deep dive on TypeScript error handling in try catch blocks, you know that the unknown type in catch clauses forces you to handle the possibility that errors aren’t always Error instances. The same principle applies here. Your API routes can throw anything—validation errors, database errors, network errors—and a generic handler gives you a safety net. I’ve found that taking the time to set this up early saves hours of debugging later.
Middleware that runs before your route handler
API routes support middleware—code that runs before your handler and can modify the request, add headers, or short-circuit with a response. In the Pages Router, you’d wrap your handler. In the App Router, you use the middleware.ts file at the project root, which runs for both pages and API routes.
For API-specific middleware—like authentication checks or rate limiting—you can create helper functions that wrap individual route handlers:
function withAuth(handler: Function) {
return async (request: Request, context: any) => {
const token = request.headers.get('authorization')?.replace('Bearer ', '');
if (!token) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
const user = await verifyToken(token);
if (!user) {
return Response.json({ error: 'Invalid token' }, { status: 401 });
}
// Attach user to request for downstream handlers
const authenticatedRequest = Object.assign(request, { user });
return handler(authenticatedRequest, context);
};
}
export const GET = withAuth(async (request: Request) => {
const user = request.user;
const data = await db.post.findMany({ where: { authorId: user.id } });
return Response.json(data);
});
This composable approach lets you build a toolkit of reusable middleware. Authentication, rate limiting, request validation, logging—each one is a function that wraps a handler. You combine them as needed per route. It’s like having a set of Lego bricks for your API logic. The result is consistent, testable, and easy to reason about.
Edge vs Node.js runtimes
Next.js API routes can run in two different runtimes: the default Node.js runtime, or the Edge runtime. The choice affects what APIs are available, how your code is deployed, and where it runs geographically.
Node.js routes run in a serverless function on AWS or a similar provider. You have access to the full Node.js API—filesystem, native modules, any npm package. The cold start time is typically a few hundred milliseconds. This is the right choice for most API routes, especially ones that use database libraries or need the Node.js ecosystem.
Edge routes run on Vercel’s Edge Network, close to users. They use a subset of the Web API—no filesystem access, no native modules. Cold starts are nearly instant. This is ideal for lightweight middleware, A/B testing logic, geolocation-based redirects, and API routes that need minimal latency.
export const runtime = 'edge';
export async function GET(request: Request) {
const country = request.headers.get('x-vercel-ip-country') || 'US';
const localizedGreeting = country === 'FR' ? 'Bonjour' : 'Hello';
return Response.json({ greeting: localizedGreeting });
}
The runtime export tells Next.js where to run the route. If you don’t specify, it defaults to Node.js. Edge routes have limitations—no Prisma, no fs, no long-running connections—but for simple logic that benefits from global distribution, they’re a powerful option. I’ve used edge routes for things like A/B test bucketing and geolocation-based redirects, and the speed difference is noticeable.
Streaming responses for real-time data
API routes can stream responses using the Web Streams API. Instead of waiting for all data to be available and sending one large JSON response, you can send data as it becomes available. The client reads the stream incrementally.
This pattern works well for AI responses, real-time dashboards, and any situation where the response is generated over time.
export async function POST(request: Request) {
const { prompt } = await request.json();
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
// Simulate streaming AI response
const words = prompt.split(' ');
for (const word of words) {
controller.enqueue(encoder.encode(JSON.stringify({ word }) + '\n'));
await new Promise(resolve => setTimeout(resolve, 100));
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
},
});
}
The client uses fetch and reads the response body as a stream, processing each chunk as it arrives. This is the same pattern that powers ChatGPT-style interfaces. The user sees the response build in real time rather than waiting for the entire thing to complete. It’s a fantastic way to improve perceived performance, especially when you’re dealing with long-running operations.
Caching API responses
API routes that perform expensive computations or query external services benefit from caching. Next.js provides built-in cache headers, but for fine-grained control, you can integrate a caching layer like Redis.
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const cacheKey = `search:${query}`;
const cached = await redis.get(cacheKey);
if (cached) {
return Response.json(cached);
}
const results = await performExpensiveSearch(query);
await redis.set(cacheKey, results, { ex: 3600 });
return Response.json(results);
}
If you’ve explored our guide on Redis cache design patterns, you’ll recognize this as the cache-aside pattern. The API route checks the cache first, falls back to the expensive operation, and stores the result for subsequent requests. For public API routes that serve the same data to many users, this can dramatically reduce response times and external API costs. It’s one of those optimizations that feels like cheating because it’s so effective with so little code.
Concurrency in API routes
API routes often need to fetch data from multiple sources. A dashboard endpoint might query a database, call an analytics service, and fetch feature flags—all in a single request. Doing these sequentially adds latency. Doing them concurrently with Promise.all or Promise.allSettled improves response times.
export async function GET(request: Request) {
const [userCount, revenue, recentActivity] = await Promise.all([
db.user.count(),
fetchRevenueData(),
db.activity.findMany({ take: 10 }),
]);
return Response.json({
userCount,
revenue,
recentActivity,
});
}
If one of these calls fails, should the entire request fail? That depends on the data. If the analytics service is down, you might still want to return user counts and activity. Promise.allSettled handles this gracefully, as we covered in our comparison of Promise.all vs Promise.allSettled.
const results = await Promise.allSettled([
db.user.count(),
fetchRevenueData(),
db.activity.findMany({ take: 10 }),
]);
const userCount = results[0].status === "fulfilled" ? results[0].value : null;
const revenue = results[1].status === "fulfilled" ? results[1].value : null;
const recentActivity =
results[2].status === "fulfilled" ? results[2].value : [];
The API route degrades gracefully. If one data source fails, the others still return. The client receives partial data rather than an error. This is the kind of resilience that production applications need. It’s not glamorous, but it keeps your users happy when something inevitably breaks.
Wrapping up
Next.js API routes collapse the frontend and backend into a single codebase. One project. One deployment. One set of TypeScript types shared between the client and the server. The productivity gains are real, especially for small to medium teams that don’t have the bandwidth to maintain separate backend infrastructure.
They’re not a replacement for a dedicated backend in every scenario. If your API has hundreds of endpoints, complex business logic, or needs to scale independently of your frontend, a separate backend service might make more sense. But for the vast middle ground—form handling, webhooks, simple CRUD operations, and backend-for-frontend patterns—API routes are more than enough.
The key patterns to remember: use server components for data fetching during page rendering, use API routes for mutating data and external clients. Handle errors consistently. Cache expensive responses. And don’t overcomplicate things—sometimes a simple route handler that returns JSON is all you need.
I hope this guide has given you a clearer picture of what Next.js API routes can do and how to use them effectively. They’re one of the quiet superpowers of the framework, and once you start using them, you’ll wonder why you ever did it any other way.
Building a Next.js application and need help designing your API layer? Red Surge Technology works with teams to architect backend patterns that scale with your frontend. Get in touch to discuss your project.