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 form on your marketing page can send an email.
Next.js API routes give you 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.
It's easy to underestimate how much friction this removes. I've worked on projects where the backend was a separate Express app, and every API change meant coordinating deployments across two repositories. Next.js API routes collapse that into a single codebase. One pull request changes the UI and the endpoint that powers it. That's a genuine productivity win.
But API routes aren't just a convenience. They're a design decision that shapes how you think about your application's architecture. Let me walk through how they work, the patterns worth knowing, and the tradeoffs you're signing up for.
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.
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. The Pages Router version feels more like Express. If you're starting a new project, the App Router is the future, but plenty of production applications still use the Pages Router pattern.
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?
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.
API routes 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.