When building modern web applications, you often need to call external APIs from the browser. But doing so directly introduces a whole class of problems: CORS errors, exposed API keys in client-side code, inability to set HTTP-only cookies, and no control over request/response transformations. The solution? Proxy the requests through your own server.
In this article, I’ll walk you through how to build an HTTP proxy in Next.js using two different approaches: Route Handlers and Proxy. Next.js 16 introduced Proxy (via the proxy.ts file) as a replacement for the old middleware.ts file, making the app’s network boundary explicit and running on the Node.js runtime instead of the Edge Runtime. I built a project called next-proxy that demonstrates both patterns using the PokéAPI as a target API. Let’s break down the implementation, explore the trade-offs, and understand when to use each approach.
Why Proxy Requests Through Next.js?
Before diving into code, let’s understand why this pattern matters. When your frontend directly calls an external API, the request goes from the browser to the external server. This creates several problems:
1. CORS restrictions. Browsers enforce the Same-Origin Policy. If the external API doesn’t include the right Access-Control-Allow-Origin headers, your request fails. By proxying through your Next.js server, the browser only talks to your own domain, so CORS is never an issue.
2. Sensitive credentials stay on the server. API keys, tokens, and secrets should never be shipped to the browser. With a proxy, your server reads these from environment variables and attaches them to the outgoing request. The client never sees them.
3. HTTP-only cookies. You can read and write HTTP-only cookies on the server side during the proxy process. This is critical for secure authentication flows where tokens must not be accessible via JavaScript.
4. Request and response transformation. Need to add custom headers, rewrite paths, filter response data, or implement caching? The proxy layer gives you full control over both the incoming and outgoing traffic.
5. Rate limiting and analytics. You can track how your users consume external APIs, implement rate limiting per user, and log usage patterns, all transparently at the proxy layer.
The Shared Proxy Logic
Both approaches in this project share a single core function that handles the actual proxying. This function lives in src/lib/proxy.ts and does three things: rewrites the URL path, forwards the request with filtered headers, and returns the response with cleaned-up headers.
import { NextRequest } from "next/server";import { BodyInit, fetch } from "undici";
const POKEAPI_BASE_URL = "https://pokeapi.co";const POKEAPI_BASE_PATH = "/api/v2";
const excludeResponseHeaders = [ "content-encoding", "content-length", "transfer-encoding",];
const excludeRequestHeaders = ["accept-encoding", "host", "connection"];
export async function proxyRequest(request: NextRequest, proxyPath: string) { const targetPath = request.nextUrl.pathname.replace( proxyPath, POKEAPI_BASE_PATH, ); const targetUrl = new URL(targetPath, POKEAPI_BASE_URL);
const requestHeaders = new Headers(); request.headers.forEach((value, key) => { if (!excludeRequestHeaders.includes(key.toLowerCase())) { requestHeaders.set(key, value); } });
const response = await fetch(targetUrl, { method: request.method, headers: requestHeaders, body: request?.body as BodyInit, duplex: "half", });
const responseBodyPromise = response.arrayBuffer(); const headers = new Headers(); response.headers.forEach((value, key) => { if (!excludeResponseHeaders.includes(key.toLowerCase())) { headers.set(key, value); } });
return new Response(await responseBodyPromise, { status: response.status, statusText: response.statusText, headers, });}Let’s break down the key parts.
URL Path Rewriting
const targetPath = request.nextUrl.pathname.replace( proxyPath, POKEAPI_BASE_PATH,);const targetUrl = new URL(targetPath, POKEAPI_BASE_URL);This is the core of the proxy. When a request comes in at /api/proxy/pokemon/pikachu, the function strips the proxy prefix (/api/proxy) and replaces it with the target API’s base path (/api/v2). The result is /api/v2/pokemon/pikachu, which gets combined with the base URL to form https://pokeapi.co/api/v2/pokemon/pikachu.
Header Filtering
Not all headers should be forwarded. Some cause conflicts or break the proxy:
Request headers to exclude:
accept-encoding: prevents double compression between your server and the targethost: must match the target domain, not your proxy domainconnection: connection management is handled by the HTTP client
Response headers to exclude:
content-encoding: Next.js handles its own response compressioncontent-length: gets recalculated by theResponseconstructortransfer-encoding: not applicable when constructing a newResponseobject
Body Forwarding and Duplex Mode
const response = await fetch(targetUrl, { method: request.method, headers: requestHeaders, body: request?.body as BodyInit, duplex: "half",});The request.body in Next.js is a ReadableStream. The duplex: "half" option tells undici to accept streaming request bodies. This means we can pipe the incoming body directly to the outgoing request without buffering the entire payload in memory first.
The project uses undici instead of the native fetch because it provides better control over HTTP behavior and reliable streaming support.
Approach 1: Route Handler
The Route Handler approach uses Next.js App Router’s API routes with a catch-all segment to capture all proxy requests.
import { proxyRequest } from "@/lib/proxy";import { NextRequest } from "next/server";
export async function GET(request: NextRequest) { return proxyRequest(request, "/api/proxy");}
export async function POST(request: NextRequest) { return proxyRequest(request, "/api/proxy");}
export async function PUT(request: NextRequest) { return proxyRequest(request, "/api/proxy");}
export async function PATCH(request: NextRequest) { return proxyRequest(request, "/api/proxy");}
export async function DELETE(request: NextRequest) { return proxyRequest(request, "/api/proxy");}How It Works
The [...all] catch-all segment in the folder name tells Next.js to match any path under /api/proxy/. So /api/proxy/pokemon/pikachu, /api/proxy/ability/static, and /api/proxy/type/fire all hit this single route file.
Each exported function corresponds to an HTTP method. When a GET request arrives at /api/proxy/pokemon/pikachu, Next.js calls the GET function, which delegates to proxyRequest with the proxy path prefix /api/proxy.
The request flow looks like this:
Browser: GET /api/proxy/pokemon/pikachu → Next.js Route Handler: GET function → proxyRequest() rewrites path → undici fetch: GET https://pokeapi.co/api/v2/pokemon/pikachu → Response flows back to browserAdding Custom Logic
The Route Handler approach makes it straightforward to add per-route logic. For example, you could add authentication checks, inject API keys, or implement caching:
export async function GET(request: NextRequest) { // Check authentication const session = await getSession(request); if (!session) { return new Response("Unauthorized", { status: 401 }); }
// Add API key from environment variable request.headers.set("X-Api-Key", process.env.EXTERNAL_API_KEY!);
return proxyRequest(request, "/api/proxy");}Approach 2: Proxy
Starting from Next.js 16, Proxy replaces the old Middleware as the recommended way to intercept requests at the network boundary. The key difference is that Proxy runs on the Node.js runtime instead of the Edge Runtime, giving you access to the full Node.js API. The old middleware.ts is deprecated and will be removed in a future version.
To use this approach, you simply create a proxy.ts file at the root of your src/ directory (or project root) and export a default proxy function.
import { NextRequest, NextResponse } from "next/server";import { proxyRequest } from "./lib/proxy";
export async function proxy(request: NextRequest) { if (request.nextUrl.pathname.startsWith("/proxy")) { return proxyRequest(request, "/proxy"); } else { NextResponse.next(); }}
export const config = { matcher: "/proxy/:path*" };How It Works
The config.matcher tells Next.js to only invoke the proxy function for paths matching /proxy/*. When a request hits /proxy/pokemon/pikachu, Next.js calls the proxy function before the request reaches any route handler. The function then delegates to proxyRequest with /proxy as the prefix, which rewrites the path to /api/v2/pokemon/pikachu before fetching from the target API.
The request flow:
Browser: GET /proxy/pokemon/pikachu → Next.js proxy.ts intercepts (before routing) → proxy() checks pathname → proxyRequest() rewrites path → undici fetch: GET https://pokeapi.co/api/v2/pokemon/pikachu → Response flows back to browserNotice the proxy path is /proxy instead of /api/proxy. The Proxy approach doesn’t need to live under the /api namespace since it intercepts requests before routing.
If you’re migrating from the old middleware.ts, the change is straightforward: rename middleware.ts to proxy.ts and rename the exported function from middleware to proxy. Your existing logic stays the same.
Composing with Other Logic
A real-world proxy.ts file often handles multiple concerns. You can compose the proxy forwarding with other request interception logic like authentication and redirects:
import { NextRequest, NextResponse } from "next/server";import { proxyRequest } from "./lib/proxy";
export default function proxy(request: NextRequest) { // Handle proxy routes if (request.nextUrl.pathname.startsWith("/proxy")) { return proxyRequest(request, "/proxy"); }
// Handle other logic (auth, redirects, etc.) const token = request.cookies.get("auth-token"); if (!token && request.nextUrl.pathname.startsWith("/dashboard")) { return NextResponse.redirect(new URL("/login", request.url)); }
return NextResponse.next();}Pros and Cons
Now that we’ve seen both approaches in action, let’s compare them.
Route Handler
Pros:
- Explicit and discoverable. Each proxy endpoint is a file in your
app/api/directory. New developers can immediately see what proxy routes exist by browsing the folder structure. - Per-method control. You export individual functions for each HTTP method, making it easy to handle
GETdifferently fromPOST. For example, you can cache GET responses while validating POST bodies. - Access to the full Node.js runtime. Route Handlers run in the Node.js runtime by default, giving you access to file system, database connections, and heavy computation. (Note that Proxy in Next.js 16 also runs on Node.js, so this is no longer a differentiator between the two approaches.)
- Easier to test. You can test route handlers as regular async functions. No special middleware test harness needed.
- Built-in Next.js features. Route Handlers integrate naturally with Next.js caching, revalidation, and streaming responses.
Cons:
- Slightly higher latency. The request goes through Next.js routing before reaching your handler. For most applications, this difference is negligible.
- URL namespace. Your proxy routes must live under
/api/, which may feel less clean if you want URLs like/external-service/.... - One route per proxy target. If you proxy to multiple external services, you need multiple catch-all routes (e.g.,
/api/service-a/[...all],/api/service-b/[...all]).
Proxy
Pros:
- Runs before routing. Proxy intercepts requests before they hit the route handler layer, making it ideal for cross-cutting concerns.
- Full Node.js runtime. Unlike the old Middleware which ran on the Edge Runtime, Proxy runs on Node.js. This means you have access to
fs, native database drivers, and any Node.js library without restrictions. - Clean URL paths. Your proxy can live at any path (e.g.,
/proxy/...) without being forced under the/api/namespace. - Centralized request interception. All proxy logic lives in a single file, making it easy to apply consistent behavior across multiple proxy paths.
- Clear network boundary. The naming makes the intent explicit: this file handles the network boundary of your application.
Cons:
- Single file constraint. All interception logic lives in one
proxy.tsfile. As it grows with authentication, redirects, logging, and multiple proxy targets, this file can become complex and harder to maintain. - Harder to debug. Errors in
proxy.tscan be less straightforward to trace since it runs outside the normal routing flow. - No per-method granularity by default. You receive the raw
NextRequestand must checkrequest.methodyourself if you want method-specific behavior. - No Edge Runtime. If you specifically need edge execution for lower latency on platforms like Vercel, Proxy does not support it since it runs exclusively on Node.js. The deprecated
middleware.tsstill supports Edge Runtime for now, but it will be removed in a future version.
When to Use Which?
Here’s my rule of thumb:
Use Route Handlers when:
- You want per-route, per-method logic with clear separation
- You’re building a BFF (Backend for Frontend) where each proxy route has distinct business logic
- Testing and maintainability are priorities
Use Proxy when:
- Your proxy is a simple pass-through with minimal transformation
- You need to intercept requests before they reach the routing layer
- You’re combining proxying with other request interception concerns (auth, redirects, rate limiting)
- You want a clear, centralized network boundary for your application
In practice, the Route Handler approach is the safer default for most applications. It’s more explicit, easier to test, and keeps proxy logic neatly scoped per endpoint. The Proxy approach shines when you need centralized request interception or when the proxy is part of a broader request pipeline that includes authentication, redirects, and other cross-cutting concerns.
Conclusion
Proxying HTTP requests through Next.js is a powerful pattern that solves real problems: CORS issues, credential exposure, and lack of server-side control over external API calls. Both Route Handlers and Proxy give you a clean way to implement this, each with distinct trade-offs.
The Route Handler approach offers explicitness, per-method control, and easy testability, making it ideal for most use cases. The Proxy approach, introduced in Next.js 16 as a replacement for Middleware, provides centralized request interception with full Node.js runtime access, making it best for simple pass-through proxies or when combined with other cross-cutting logic like authentication and redirects.
Both approaches share the same core proxy function, which handles URL rewriting, header filtering, and body forwarding. This shared logic pattern keeps things DRY and makes it easy to switch between approaches or even use both simultaneously in the same project.
If you want to see the full working implementation with an interactive demo UI, check out the next-proxy repository on GitHub.