Route handlers in Next.js 13.2+ provide a cleaner, platform-native alternative to legacy API Routes. They use the standard Request and Response Web APIs and make it easy to build server-side logic and REST endpoints with familiar HTTP methods like GET, POST, PUT, and DELETE.
Beyond simple CRUD, route handlers are ideal for tasks that don’t belong in React components, such as file generation, data transformation, proxying, and other backend-for-frontend responsibilities.
This article walks through 3 production-ready use cases that show how to apply route handlers effectively in real Next.js applications.
Route handlers are ideal whenever your application needs server-side logic that sits outside the React rendering lifecycle. In practice, they’re a great fit for:
These patterns become increasingly powerful as your application grows and your API endpoints take on backend-for-frontend duties.
For this walkthrough, we start with a Next.js 16 dashboard application based on the current official App Router tutorial.
The project has been extended with:
Throughout the article, you'll build 3 advanced server-side capabilities:
By the end, you'll see how route handlers can cleanly encapsulate backend logic without needing a separate API service.
This guide assumes you’re comfortable working with:
page.ts|js files in the Next.js 16 App RouterTwo of the examples rely on Vercel Edge Config, so it's helpful to be familiar with:
vercel env pull@vercel/edge-config SDK for accessing config values in route handlersTo follow along after forking the starter repository, you’ll need:
npx create-next-app --example https://github.com/anewman15/nextjs-route-handlers/tree/prepareThe starter code for the example Next.js 16 app router API application is available in the prepare branch here. It has a similar-looking dashboard application with customers and invoices resources as the original Next.js tutorial. Some features have been discarded for brevity, focusing on the topics of this post.
For demonstration purposes, the base invoices REST endpoints are already implemented in this branch. You’ll expand on these throughout the article as you build more advanced functionality.
To get the project running locally:
npx create-next-app --example https://github.com/anewman15/nextjs-route-handlers/tree/prepareapp directory and install dependencies1npm i1npm run devhttp://localhost:3000/dashboard. You’ll see the classic Next.js dashboard with routes for:/dashboard/dashboard/invoices/dashboard/customersThis dashboard fetches resources from a Strapi backend hosted at: https://proud-acoustics-2ed8c6b135.strapiapp.com/api.
Because the data comes from a cloud-hosted CMS, you’ll need an active internet connection as you follow along.
The examples in this article assume you are working from the prepare branch.
The fully completed project lives in the main branch if you want to inspect the final implementation.
The starter code makes heavy use of route handlers (route.ts) to implement its REST API. This aligns with the recommended pattern in the App Router: each nested segment can define its own API surface using exported HTTP method functions, such as:
GETPOSTPUTDELETEExample: Collection Endpoint: We will use a GET() and a POST() handler for invoices endpoints at the /api/v2/invoices/(locales)/en-us routing level:
1// Path: app/api/v2/invoices/(locales)/en-us/route.ts
2
3import { NextRequest, NextResponse } from "next/server";
4import { invoices } from "@/app/lib/strapi/strapiClient";
5
6export async function GET() {
7
8 try {
9 const allInvoices = await invoices.find();
10
11 return NextResponse.json(allInvoices);
12 } catch (e: any ){
13 return NextResponse.json(e, { status: 500 });
14 }
15};
16
17export async function POST(request: NextRequest) {
18 const formData = await request.json();
19
20 try {
21 const createdInvoice = await invoices.create(formData);
22
23 return NextResponse.json(createdInvoice);
24 } catch (e: any){
25
26 return NextResponse.json(e, { status: 500 });
27 };
28};As it goes with React views in the page.ts file, we can use dynamic route segments to define endpoints for an individual item (invoices item in this case) with its :ids.
Example: Single-Item Endpoint: So, we have a route handler at /api/v2/invoices/(locales)/en-us/[id]. For this segment, we have a GET(), a PUT() and a DELETE() handler in the route.ts file:
1// Path: app/api/v2/invoices/(locales)/en-us/[id]/route.ts
2
3import { NextRequest, NextResponse } from "next/server";
4import { invoices } from "@/app/lib/strapi/strapiClient";
5
6export async function GET(
7 request: NextRequest,
8 { params }: { params: Promise<{ id: string }>}
9) {
10 const id = (await params).id;
11
12 try {
13 const invoice = await invoices.findOne(id);
14
15 return NextResponse.json(invoice);
16 } catch (e: any) {
17 return NextResponse.json(e, { status: 500 });
18 };
19};
20
21export async function PUT(
22 request: NextRequest,
23 { params }: { params: Promise<{ id: string }> }
24) {
25
26 const id = (await params).id;
27 const formData = await request.json();
28
29 try {
30 const invoice = await invoices.update(id, formData);
31
32 return NextResponse.json(invoice);
33 } catch (e: any) {
34 return NextResponse.json(e, { status: 500 });
35 };
36};
37
38export async function DELETE(
39 request: NextRequest,
40 { params }: { params: Promise<{ id: string }> }
41) {
42
43 const id = (await params).id;
44
45 try {
46 const invoice = await invoices.delete(id);
47
48 return NextResponse.json(invoice);
49 } catch (e: any) {
50 return NextResponse.json(e, { status: 500 });
51 };
52};The Next.js 16 route.ts file facilitates hosting multiple REST API endpoints by allowing more than one handler/action export. So, for each action for an invoices item at /api/v2/invoices/(locales)/en-us/[id], we have:
GET() handler that serves an invoices item with :id,PUT() handler that helps update an invoices item with :id,DELETE() handler that helps delete an invoices item with :id.NextRequest and NextResponse APIs in Next.js 16Route handlers use NextRequest and NextResponse, which extend the native Web APIs (Request and Response) with features optimized for the Next.js runtime:
These abstractions make building REST endpoints in the App Router ergonomic and familiar.
Authentication can be introduced at multiple layers in a Next.js App Router application:
In our case above, we are handling the Strapi token authentication very granularly at every request with the strapiClient initialized in app/lib/strapi/strapiClient.ts:
1// Path: app/lib/strapi/strapiClient.ts
2
3import { strapi } from "@strapi/client";
4
5const strapiClient = strapi({
6 baseURL: process.env.NEXT_PUBLIC_API_URL!,
7 auth: process.env.API_TOKEN_SALT,
8});
9
10export const revenues = strapiClient.collection("revenues");
11export const invoices = strapiClient.collection("invoices");
12export const customers = strapiClient.collection("customers");This strapiClient sends the auth token as a header at every Strapi request, so in this case, there is no need for authorizing backend Strapi requests with a specialized/shared middleware.
So far, we’ve used route handlers for relatively standard REST endpoints. But route handlers really shine when you start delegating heavier backend responsibilities to them.
Because they run on the server and are built on top of the Web Request/Response APIs, route handlers are a great fit for tasks like:
The trade-off is always cost vs performance: you want to keep serverless invocations efficient while still doing useful work at the edge of your app.
In the next sections, we’ll walk through three concrete, production-style use cases:
A classic pattern for route handlers is file generation: fetch data, transform it, and stream it back as a downloadable file.
In this example, we want to:
invoices.csv file from a route handlerWe’ll create a route at /api/v2/invoices/(locales)/en-us/csv:
1// Path : ./app/api/v1/invoices/(locales)/en-us/csv.route.ts
2
3import { json2csv } from "json-2-csv";
4import { invoices } from "@/app/lib/strapi/strapiClient";
5
6export async function GET() {
7
8 const allInvoices = await invoices.find({
9 populate: {
10 customer: {
11 fields: ["name", "email"],
12 },
13 },
14 });
15
16 const invoicesCSV = await json2csv(allInvoices?.data, {
17 expandNestedObjects: true,
18 });
19
20 const fileBuffer = await Buffer.from(invoicesCSV as string, "utf8");
21
22 const headers = new Headers();
23 headers.append('Content-Disposition', 'attachment; filename="invoices.csv"');
24 headers.append('Content-Type', "application/csv");
25
26 return new Response(fileBuffer, {
27 headers,
28 });
29};Here’s what happens step-by-step:
invoices.find() fetches invoice data (including related customer info) from Strapi.json2csv converts the JSON array into CSV.Buffer from the CSV string.Response containing the CSV buffer.Now, any GET request to /api/v2/invoices/(locales)/en-us/csv returns a downloadable invoices.csv file.
In the UI, we simply wire a “Download as CSV” button on the /dashboard/invoices page to this endpoint:
This keeps file generation purely server-side, with a minimal surface in your React components.
For the second use case, we’ll combine:
This kind of pattern is useful when you want to serve region-specific views of the same underlying data.
Step 1: Currency Conversion Endpoint (/api/v2/invoices/(locales)/fr)
First, we create a route handler that:
invoices.find()NextResponse1// Path: ./app/api/v2/invoices/(locales)/fr/route.ts
2
3import { NextResponse } from "next/server";
4import { invoices } from "@/app/lib/strapi/strapiClient";
5
6export async function GET() {
7 let invoicesInUSD;
8 let USDRates;
9
10 try {
11 invoicesInUSD = await invoices.find({
12 fields: ["date", "amount", "invoice_status"],
13 populate: {
14 customer: {
15 fields: ["name", "email", "image_url"],
16 },
17 },
18 });
19
20 USDRates = (await (
21 await fetch("https://open.er-api.com/v6/latest/USD"))
22 .json()
23 )?.rates?.EUR;
24
25 const USDtoEURRate = USDRates || (0.86 as number);
26 const invoicesJSON = await JSON.parse(JSON.stringify(invoicesInUSD?.data));
27
28 const invoicesInEUR = await invoicesJSON.map((invoice: any) => ({
29 date: invoice?.date,
30 amount: USDtoEURRate * invoice?.amount,
31 invoice_status: invoice?.invoice_status,
32 customer: invoice?.customer,
33 }));
34
35 return NextResponse.json({ data: [...invoicesInEUR] });
36 } catch (e: any) {
37 return NextResponse.json(e, { status: 500 });
38 };
39};Key points:
We are able to access this directly from the /api/v2/invoices/(locales)/fr endpoint. However, we want to implement a location-based proxy that redirects routing based on the request IP address. In particular, if the user is requesting /api/v1/invoices from an IP located in FR, we want to redirect them to /api/v2/invoices/(locales)/fr. Otherwise, we send the invoices as in v1.
Let's now add a location proxy route handler.
Step 2: Location-Based Proxy (/api/v1/invoices)
Next, we create a location-aware proxy route:
/fr endpoint.1// Path: app/api/v1/invoices/route.ts
2
3import { NextRequest, NextResponse } from "next/server";
4import { invoices } from "@/app/lib/strapi/strapiClient";
5
6export async function GET(request: NextRequest) {
7 const ip = (await request.headers.get("x-forwarded-for"))?.split(",")[0];
8 // const ip = "51.158.36.186"; // FR based ip
9
10 let allInvoices;
11 let country = "FR";
12
13 try {
14 allInvoices = await invoices.find();
15 country = (await (await fetch(`http://ip-api.com/json/${ip}`)).json())?.countryCode;
16
17 if (country !== "FR") {
18 return NextResponse.json(allInvoices);
19 };
20
21 return NextResponse.redirect(new URL("/api/v2/invoices/fr", request.url), {
22 status: 302,
23 });
24 } catch (e: any) {
25 return NextResponse.json(e, { status: 500 });
26 };
27};Behavior:
/api/v1/invoices and receive the default USD dataset./api/v2/invoices/fr, where amounts are converted to EUR.This is a perfect example of route handlers acting as a focused, backend-for-frontend layer: regional logic lives in the API, not in your React components.
For the final example, we’ll build a redirect management system that:
This pattern scales to thousands of redirects while keeping middleware fast and lightweight.
Step 1: Store Redirects in Edge Config
For the first step, we should add a redirects map to an Edge Config Store. Use this Vercel Edge Config guide to add a redirects map to Vercel Edge Config.
1{
2 "-api-v1-revenues": {
3 "destination": "/api/v2/revenues/en-us",
4 "permanent": false
5 },
6 "-api-v1-revenues-fr": {
7 "destination": "/api/v2/revenues/en-us",
8 "permanent": true
9 },
10 "-api-v1-revenues-de": {
11 "destination": "/api/v2/revenues/en-us",
12 "permanent": false
13 }
14}Notes:
/api/v1/revenues → -api-v1-revenues).Follow Vercel’s docs to:
vercel env pull to bring environment variables into your local env.local fileStep 2: Route Handler for a Single Redirect Item
Next, we expose a route handler that returns a single redirect entry from Edge Config.
This keeps the middleware simple; it doesn’t have to know how to talk to Edge Config directly.
1// Path: app/api/redirects/[path]
2
3import { NextRequest, NextResponse } from "next/server";
4import { get } from "@vercel/edge-config";
5
6export async function GET(
7 request: NextRequest,
8 { params }: { params: Promise<{ path: string }> }
9) {
10 try {
11 const path = (await params)?.path;
12 const redirectEntry = await get(path);
13
14 return NextResponse.json(redirectEntry);
15 } catch (e: any) {
16 return NextResponse.json(e, { status: e.status });
17 };
18};What this does:
Step 3: Middleware to Apply Redirects Finally, we wire up a middleware that:
pathname into a redirect keyedgeConfigHas() to quickly check if a redirect exists for that key307 or 308 depending on the permanent flagAdd a proxy.ts (as opposed to middleware.ts, which is in version 15) file and use the following code:
1// Path: proxy.ts
2
3/* eslint-disable @typescript-eslint/no-explicit-any */
4import { NextRequest, NextResponse } from "next/server";
5import { has as edgeConfigHas } from "@vercel/edge-config";
6
7export interface RedirectsEntry {
8 destination: string;
9 permanent: boolean;
10};
11
12export type RedirectsRecord = Record<string, RedirectsEntry>;
13
14export async function proxy(request: NextRequest) {
15 const pathname = request?.nextUrl?.pathname;
16 const redirectKey = pathname?.split("/")?.join("-");
17
18 try {
19 const edgeRedirectsHasRedirectKey = await edgeConfigHas(redirectKey);
20
21 if (edgeRedirectsHasRedirectKey) {
22 const redirectApi = new URL(
23 `/api/redirects/${redirectKey}`,
24 request.nextUrl.origin
25 );
26 const redirectData = await fetch(redirectApi);
27
28 const redirectEntry: RedirectsEntry | undefined =
29 await redirectData?.json();
30 const statusCode = redirectEntry?.permanent ? 308 : 307;
31
32 return NextResponse.redirect(
33 new URL(redirectEntry?.destination as string, request.nextUrl.origin),
34 statusCode
35 );
36 }
37
38 return NextResponse.next();
39 } catch (e: any) {
40 return NextResponse.json(e, { status: e.status });
41 };
42};
43
44export const config = {
45 matcher: ["/(api/v1/revenues*.*)"],
46};The whole point of a Next.js root middleware is to keep its operations lightweight so that it can make faster routing decisions.
has() to quickly decide whether the redirect map has a key that represents the requested URL pathname. -, and _.NextResponse.next(). So, this improves routing decisions on the spot.On the other hand, if the redirects map has an item for the pathname being requested, we have additional things to do:
pathname that represents a redirect URL key.fetch() request to the redirects key endpoint we created above at /api/redirects/[path] for this redirect URL key. Get that redirect entry via the route handler dedicated to this URL key.redirectEntry.destination.So, here, we could have sent a get() request in the first place to Edge Config, but that would be a lengthy thing for the middleware to tackle -- particularly costly for a request that does not have a redirect URL. This becomes obvious when you have more and more entries in your redirects map.
Since has() is quicker and we have a route handler to tackle lengthy data fetching, we are choosing to keep our middleware performant. Essentially, for an efficient specialty middleware, we have handed off the otherwise slower yielding task of data fetching over to a route handler, which does it well in itself under the hood.
Beyond the 3 examples above, Next.js 16 route handlers are a great fit for:
draftMode APIs, where third-party CMS data is accessed via a route handler.While route handlers bring a powerful backend-for-frontend model to the App Router, they also introduce important architectural considerations. To use them effectively, especially for non-trivial backend logic, you need to be aware of their runtime constraints and apply performance-friendly patterns.
Because Next.js deploys route handlers as serverless functions (Node or Edge runtimes), they come with inherent limitations:
These constraints should guide how much work you push into a route handler and when to offload tasks elsewhere.
Just like other aspects of Next.js 16, route handler best practices obviously involve making proper trade-offs between cost and performance.
Since Next.js route handlers are serverless and subject to timeouts, we need to make sure route handlers are implemented in such a way that optimizes persistent performance against the cost of the processes handled at the route.
To get consistent performance and predictable behavior, keep the following best practices in mind:
In this post, we explored how Next.js 16 route handlers can power both standard and advanced server-side features within an App Router application. We began by reviewing how route handlers implement RESTful JSON APIs and then added a CSV file download endpoint built entirely in a route handler.
From there, we demonstrated how route handlers can take on more complex backend tasks, including a currency-conversion API backed by a location-aware proxy layer. We also implemented a redirect-management middleware using Vercel Edge Config and saw how a dedicated route handler improves performance by handling redirect lookups outside the middleware.
Finally, we touched on additional advanced use cases, along with the limitations and best practices to keep in mind when using route handlers for production workloads.
Open source enthusiast. Full Stack Developer passionate about working on scalable web applications.