Documentation

nextjs-proxy

A single, hardened entry point for every outbound API call in the Next.js App Router — SSRF protection, CORS, rate limiting, streaming, and request transformation in one handler.

v2.3.0MITApp RouterNext 13–16

Getting started

Introduction

Without a proxy, a client-facing route that fetches an arbitrary URL leaks credentials and opens you to Server-Side Request Forgery. nextjs-proxy replaces that pattern with one configurable handler that runs every security guard before any upstream request.

Recommended: use named routes so the client sends a route name instead of a URL — the server decides the destination and client-driven SSRF becomes impossible.

Getting started

Installation

Install from npm with your package manager of choice. The package is Turbopack-compatible from the registry.

terminal
1# pnpm
2pnpm add nextjs-proxy
3
4# npm
5npm install nextjs-proxy
6
7# yarn
8yarn add nextjs-proxy

Getting started

Quick start

Create a route handler and export it. The proxy is an App Router route handler — Pages Router API routes (pages/api/*) are not supported.

app/api/proxy/route.ts
1// app/api/proxy/route.ts
2import { nextProxyHandler } from "nextjs-proxy"
3
4export const POST = nextProxyHandler({
5 baseUrl: "https://api.my-service.com",
6 allowOrigins: ["https://app.my-domain.com"],
7 inMemoryRate: { windowMs: 60_000, max: 100 },
8})

Getting started

The request contract

The client always sends a JSON body. The proxy reads four fields — method, endpoint, data, and route — and never trusts anything else from the client. An Authorization header on the incoming request is forwarded upstream as a Bearer token.

client.ts
1// The client always POSTs a JSON body. Shape:
2// { method, endpoint, data?, route? }
3// Prefer { route } so the server owns the destination.
4
5// Named route (recommended):
6await fetch("/api/proxy", {
7 method: "POST",
8 headers: { "content-type": "application/json" },
9 body: JSON.stringify({ route: "profile", method: "GET" }),
10})
11
12// Relative endpoint (resolved via baseUrl):
13await fetch("/api/proxy", {
14 method: "POST",
15 headers: { "content-type": "application/json" },
16 body: JSON.stringify({ method: "POST", endpoint: "/orders", data: { sku: "X1" } }),
17})

GET and HEAD are sent without a body. For every other method, data is JSON-encoded as the upstream body with Content-Type: application/json. A relative endpoint requires baseUrl; an absolute one must pass the allowedHosts check.

Getting started

Request lifecycle & guards

Every request runs through an ordered chain of guards before any upstream fetch. The first guard that fails short-circuits the request with its own status code — so streaming, transforms, and the outbound call are never reached on a denied request.

  1. 1auth 401 Unauthorized (auth) on failure
  2. 2csrf 403 Forbidden (csrf/xss) on failure
  3. 3CORS origin (OPTIONS preflight + actual request) 403 Origin not allowed on failure
  4. 4inMemoryRate 429 Rate limit exceeded on failure
  5. 5rateLimit (external hook) 429 Rate limit exceeded on failure
  6. 6validate 401 Unauthorized on failure
  7. 7Named-route resolution + SSRF host check 400 / 403 on failure
app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 // Guards run top-to-bottom BEFORE the upstream fetch.
3 // The first one that fails short-circuits with its status.
4 auth: (req) => Boolean(req.headers.get("authorization")), // 401
5 csrf: (req) => req.headers.get("x-csrf") === process.env.CSRF, // 403
6 // CORS origin check (allowOrigins) ............................ 403
7 inMemoryRate: { windowMs: 60_000, max: 100 }, // 429
8 rateLimit: async (req) => myRedisLimiter.check(req), // 429
9 validate: async (req) => userCanAccess(req), // 401
10 // SSRF host check on the resolved endpoint ................... 403
11})

Because the SSRF host check is the last guard before the fetch, even an authenticated, in-quota request cannot reach a disallowed host. Guards are independent — configure only the ones you need.

Security

Named routes

The routes option maps a name to a trusted, server-defined destination. The client never controls where the request goes — the strongest SSRF posture and the recommended way to use the proxy.

app/api/proxy/route.ts
1// app/api/proxy/route.ts
2export const POST = nextProxyHandler({
3 // The client sends { route: "profile" } — never a URL.
4 routes: {
5 profile: "https://api.my-service.com/v1/me",
6 billing: "https://billing.internal/v2/invoices",
7 },
8 allowOrigins: ["https://app.my-domain.com"],
9})
10
11// Client
12await fetch("/api/proxy", {
13 method: "POST",
14 body: JSON.stringify({ route: "profile" }),
15})

Resolved routes bypass allowedHosts but still respect the http/https and internal-host checks. An unknown route returns 400 Unknown route without disclosing which names exist; inherited keys like constructor are never resolved.

Security

SSRF protection

For absolute endpoints, host validation is enforced by default (v2.0.0). The host of baseUrl is implicitly trusted for relative endpoints.

app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 // Allowlist of destination hosts for absolute endpoints.
3 allowedHosts: ["api.stripe.com", "*.my-service.com"],
4
5 // Block internal / loopback / metadata hosts (default: false).
6 allowPrivateHosts: false,
7})
8// Absolute endpoints outside the allowlist -> 403 "Endpoint not allowed"

With allowPrivateHosts: false the proxy blocks 127.0.0.1, localhost, cloud metadata (169.254.169.254), and the 10/8, 172.16/12, 192.168/16 ranges plus their IPv6 equivalents. Denials return a generic 403 Endpoint not allowed; the reason goes only to log.

Security

CORS & credentials

Set the allowlist with allowOrigins. Preflight handling is zero-config; a denied OPTIONS returns a clean 403 with no Access-Control-Allow-* headers.

app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 // Required: explicit allowlist (never "*") when using credentials.
3 allowOrigins: ["https://app.my-domain.com"],
4
5 // Emits Access-Control-Allow-Credentials: true and reflects the origin.
6 corsCredentials: true,
7})

Combining corsCredentials with a wildcard or unset allowOrigins throws at construction — reflecting the origin with credentials would leak a credentialed response to any origin.

Security

Rate limiting

inMemoryRate groups requests by IP or a custom key. It is per-instance best-effort on serverless — plug a shared RateLimitStore (e.g. Redis) for global limits.

app/api/proxy/route.ts
1import { nextProxyHandler, InMemoryRateLimitStore } from "nextjs-proxy"
2
3export const POST = nextProxyHandler({
4 inMemoryRate: {
5 windowMs: 60_000,
6 max: 100,
7 key: (req) => req.headers.get("x-api-key") ?? "anon",
8 // Plug a shared backend (e.g. Redis) for global limits:
9 // store: new RedisRateLimitStore(),
10 },
11})

Features

Streaming passthrough

The stream option (v2.2.0) pipes the upstream body straight to the client for Server-Sent Events, NDJSON, and LLM token streaming. Additive and backward compatible.

app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 baseUrl: process.env.LLM_API_BASE,
3 // true | "auto" | (req) => boolean | "auto"
4 stream: "auto", // pipe only when upstream Content-Type is stream-like
5})
6// All guards (auth, CSRF, CORS, rate limit, validate, SSRF)
7// run BEFORE the fetch — streaming never bypasses them.

Only content-type and cache-control are forwarded; every other upstream header (including Set-Cookie) is dropped. Adds X-Content-Type-Options: nosniff and X-Accel-Buffering: no for event streams. timeoutMs only guards time-to-headers.

Features

Transform & masking

Reshape the request before the upstream fetch, the response before it reaches the client, and mask sensitive keys in transit. transformResponse is skipped for streamed bodies.

app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 baseUrl: "https://api.my-service.com",
3
4 transformRequest: ({ method, endpoint, data }) => ({
5 method,
6 endpoint,
7 data: { ...data, source: "web" },
8 }),
9
10 transformResponse: (res) => ({ ok: true, data: res }),
11
12 maskSensitiveData: (data) => ({ ...data, password: "***" }),
13})

If transformRequest rewrites the endpoint, the named-route trust is dropped and the resulting URL is re-validated against allowedHosts.

Features

Errors & status codes

A 500 never serializes the internal error to the client (Internal proxy error); full detail goes only to log. Set a timeout to abort slow upstreams. Every status the handler can return:

StatusBodyWhen
401{ error: "Unauthorized (auth)" }auth hook returns false
403{ error: "Forbidden (csrf/xss)" }csrf hook returns false
403{ error: "Origin not allowed" }origin not in allowOrigins (or onCorsDenied body)
429{ error: "Rate limit exceeded" }inMemoryRate or rateLimit denies
401{ error: "Unauthorized" }validate hook returns false
400{ error: "Named routes are not configured" }client sent route but no routes option
400{ error: "Unknown route" }route name not resolvable
400{ error: "Missing method or endpoint" }neither route nor method+endpoint present
400{ error: "Relative endpoint without baseUrl" }relative endpoint and no baseUrl
403{ error: "Endpoint not allowed" }SSRF: host blocked / not allowlisted
504{ error: "Upstream request timed out" }upstream exceeded timeoutMs
500{ error: "Internal proxy error" }any unexpected error (detail only in log)
2xx–5xxupstream body (passthrough)successful proxy forwards upstream status & body
app/api/proxy/route.ts
1export const POST = nextProxyHandler({
2 timeoutMs: 30_000, // aborts the upstream fetch; 0 disables. Timeout -> 504
3 log: (event) => {
4 // "request" | "response" | "error" — full detail stays server-side
5 console.log("[proxy]", event)
6 },
7})

Features

Middleware (Next.js 16)

nextjs-proxy is a route handler, not middleware — all guards run inside the handler. If you also want a coarse edge pre-filter, note that Next.js 16 renamed middleware.ts to proxy.ts and the export middleware to proxy. That naming applies to the framework file; the package API is unchanged across Next 13–16.

proxy.ts
1// nextjs-proxy is a ROUTE HANDLER, not middleware — it lives in
2// app/api/proxy/route.ts. You can still gate that route from the
3// framework's edge file if you want a coarse pre-filter.
4
5// Next 13–15: middleware.ts | export middleware
6// Next 16: proxy.ts | export proxy
7export function proxy(req) {
8 // optional: cheap allowlist / header check before the handler runs
9}
10export const config = { matcher: "/api/proxy" }

Client

Client-side usage (v2.3.0)

Starting in v2.3.0, the package ships client-side helpers that abstract the POST-to-proxy pattern into a clean, typed API. Use them in React Client Components, plain .ts modules, or anywhere you call fetch.

client.ts
1import {
2 proxyFetch,
3 useProxyFetch,
4 ProxyFetchProvider,
5} from "nextjs-proxy"
6
7// All three are re-exported from the same package.
8// No extra dependencies required.

The helpers are client-only. On the server during SSR, typeof window === "undefined" causes the hook to skip fetching — proxy calls happen only on the client after hydration.

Client

proxyFetch()

proxyFetch() is the core client helper. It constructs the JSON payload (method, route, data, headers), POSTs it to the proxy endpoint, and returns a typed response. HTTP errors (4xx, 5xx) are returned in the response — only network failures throw.

Response shape

FieldTypeDescription
okbooleantrue when HTTP status is 2xx
statusnumberHTTP status code (200, 404, 500, etc.)
dataT | undefinedParsed response body (present when ok === true)
errorErrorInfo | undefinedNormalized error (present when ok === false)
headersHeaders | undefinedResponse headers from the proxy endpoint

Error classification

TypeWhenBehavior
serverHTTP 4xx / 5xxReturned in response.error — does NOT throw
networkDNS fail, CORS, network downproxyFetch() throws TypeError
timeoutAbortController timeoutproxyFetch() throws AbortError
unknownUnexpected error shapeproxyFetch() throws the raw error
client.ts
1import { proxyFetch } from "nextjs-proxy"
2
3interface User {
4 id: number
5 name: string
6}
7
8// Basic GET (typed response):
9const res = await proxyFetch<User>({
10 route: "user",
11 data: { id: 42 },
12})
13// method defaults to "GET"
14// url defaults to "/api/proxy"
15
16if (res.ok) {
17 console.log(res.data.name) // ✅ typed
18} else {
19 console.log(res.status, res.error)
20}
21
22// POST with data:
23const created = await proxyFetch({
24 route: "users",
25 method: "POST",
26 data: { name: "Alice" },
27 headers: { "X-Request-ID": "abc" },
28})
29
30// The proxyFetch function does NOT throw on HTTP errors.
31// Only network failures (DNS, CORS, timeout) throw.
32// Error classification:
33// "server" ↦ HTTP 4xx/5xx (returned in response.error)
34// "network" ↦ fetch threw TypeError (caught with try/catch)
35// "timeout" ↦ AbortError (caught with try/catch)

Client

useProxyFetch()

useProxyFetch() wraps proxyFetch() with React state management. It returns loading, data, error, and a refetch() function. Polling via refetchInterval starts after the first response and cleans up on unmount — no memory leaks.

Return value

FieldTypeDescription
dataT | undefinedResponse data (present on success)
errorErrorInfo | undefinedNormalized error (present on failure)
loadingbooleantrue while a fetch is in flight
refetch() => Promise<void>Manually re-run the request (debounced)

Polling: starts after the first response to avoid a race condition on mount. Continues on error. Cleaned up on unmount. Manual refetch() restarts the interval.

Debounce: refetch() is a no-op while a fetch is already in progress. Prevents accidental double-clicks or rapid re-renders from firing concurrent requests.

component.tsx
1import { useProxyFetch } from "nextjs-proxy"
2
3function UserProfile({ userId }: { userId: number }) {
4 const { data, error, loading, refetch } = useProxyFetch<User>({
5 route: "user",
6 data: { id: userId },
7 enabled: true, // fetch on mount (default)
8 })
9
10 if (loading) return <div>Loading...</div>
11 if (error) return <div>{error.message}</div>
12 if (!data) return <div>No data</div>
13
14 return (
15 <div>
16 <p>{data.name}</p>
17 <button onClick={refetch}>Refresh</button>
18 </div>
19 )
20}
21
22// Polling — auto-refetch every 5 s:
23function LiveNotifications() {
24 const { data: notifications } = useProxyFetch({
25 route: "notifications",
26 refetchInterval: 5_000, // starts after first response
27 })
28 return <ul>{notifications?.map(n => <li key={n.id}>{n.message}</li>)}</ul>
29}
30
31// Callbacks:
32useProxyFetch({
33 route: "orders",
34 onSuccess: (data) => trackEvent("orders_loaded", data),
35 onError: (err) => reportError(err),
36})

Client

ProxyFetchProvider

ProxyFetchProvider is an optional React Context provider that injects a proxy endpoint URL into all child proxyFetch() and useProxyFetch() calls.

URL resolution priority

  1. 1Per-call url option highest priority — forces this URL for this call only
  2. 2Context URL (from ProxyFetchProvider) used when no per-call url is given
  3. 3Default "/api/proxy" used when no provider is present and no url is given
app/layout.tsx
1import {
2 ProxyFetchProvider,
3 proxyFetch,
4} from "nextjs-proxy"
5
6// Wrap your app (or a subtree):
7<ProxyFetchProvider url="/api/v2/proxy">
8 <App />
9</ProxyFetchProvider>
10
11// Any component inside the provider:
12const res = await proxyFetch({ route: "user" })
13// → uses "/api/v2/proxy"
14
15// Per-call url overrides context:
16const res2 = await proxyFetch({
17 route: "user",
18 url: "/custom-proxy", // forces this URL
19})

The provider is fully optional. If you don't need a custom URL, skip it — all calls default to /api/proxy. The provider is safe to use outside a Provider (returns the default).

Reference

Configuration reference

Every option accepted by nextProxyHandler, grouped by concern. All are optional; with no options the handler still applies the secure defaults (SSRF blocking of internal hosts, no buffered error leaks).

Destinations

routesRecord<string, string> | (name, req) => string | undefined

Named, server-controlled destinations so the client never picks the URL. Record or resolver function; return undefined to reject. The safest mode — eliminates client-driven SSRF.

baseUrlstring

Prefix used to resolve relative endpoints; its host is implicitly trusted.

allowedHostsstring | string[] | (url: URL, req) => boolean

Allowlist for absolute endpoints. Supports exact host, *.example.com, and *. Omitted ⇒ absolute endpoints are rejected and only baseUrl-relative ones are allowed.

allowPrivateHostsboolean (default false)

Opt-in escape hatch for internal / loopback / link-local / metadata hosts.

timeoutMsnumber (default 30000)

Abort the upstream fetch after N ms; 0 disables. Timeouts return 504. For streams it only guards time-to-headers.

Guards & access control

auth(req) => boolean | Promise<boolean>

Authentication check run first. Returning false short-circuits with 401 Unauthorized (auth).

csrf(req) => boolean | Promise<boolean>

CSRF / XSS check. Returning false short-circuits with 403 Forbidden (csrf/xss).

validate(req) => boolean | Promise<boolean>

Final pre-fetch authorization / permission check. Returning false401 Unauthorized.

rateLimit(req) => boolean | Promise<boolean>

External rate-limit hook (e.g. backed by Redis). Returning false429 Rate limit exceeded. Use this for strict global limits instead of inMemoryRate.

inMemoryRate{ windowMs, max, key?, store? }

In-memory rate limit by IP or custom key; pluggable store for global limits. Per-instance / best-effort on serverless.

CORS

allowOriginsstring | string[] | (origin, req) => boolean

CORS allowlist of permitted origins. Use a specific origin, list, or function (never *) with credentials.

corsCredentialsboolean (default false)

Emit Access-Control-Allow-Credentials: true and reflect the origin. Throws at construction if combined with a wildcard / unset allowOrigins.

corsMethodsstring[] (default ["POST","OPTIONS"])

Methods advertised in Access-Control-Allow-Methods.

corsHeadersstring[] (default ["Content-Type","Authorization"])

Headers advertised in Access-Control-Allow-Headers.

onCorsDenied(origin) => unknown

Custom JSON body returned when an origin is denied (defaults to { error: "Origin not allowed" }).

Transform, mask & observe

streamboolean | "auto" | (req) => boolean | "auto"

Pipe the upstream body without buffering for SSE / NDJSON / token streams. "auto" detects stream-like content types.

transformRequest(payload) => Partial<payload> | void

Reshape the payload before the upstream fetch. Receives { method, endpoint, data, route } and may return only the fields to override (or nothing). Rewriting endpoint drops named-route trust and re-validates against allowedHosts.

transformResponse(res: object) => object

Adjust the response object before it reaches the client. Must return an object; only runs for object responses and is skipped while streaming.

sanitize(data: unknown) => unknown

Sanitize the request body before transit (runs before maskSensitiveData).

maskSensitiveData(data: unknown) => unknown

Mask sensitive keys in the outbound request body before transit.

log(info: LogInfo) => void

Receive structured request / response / error events (ip, method, origin, endpoint, status, durationMs, payload).

monitor(req, res?) => void

Suspicious-activity hook called after a response (without the body for streamed responses).

Reference

Changelog

v2.3.02026-06-11Client-side helpers (proxyFetch, useProxyFetch, ProxyFetchProvider)
  • New proxyFetch() client helper with error classification, response parsing, and generic typing.
  • New useProxyFetch() React hook with loading/data/error states, polling, and debounced refetch.
  • New ProxyFetchProvider React Context for optional URL injection.
  • All exported from the package entry point — zero extra dependencies.
  • 158 total tests (66 new), client.ts 100% coverage, hooks.ts 98% coverage.
v2.2.32026-06-06Quality hardening
  • Targeted unit tests raising branch coverage from ~83% to ~91%, statement coverage from ~89% to ~96%.
  • typecheck script wired as pretest so local test runs the same type gate as CI.
  • Enabled isolatedModules; migrated project history from CHANGE.log to CHANGELOG.md.
  • 92 tests total, no runtime or API changes.
v2.2.22026-06-05Formatting & tooling
  • Reformatted the handler source to the project formatter (no runtime change).
  • No API, behavior, or security changes — the full suite, type-check, lint, build, and the Next 13/14/15 compat matrix stay green.
v2.2.12026-06-04Maintenance & tests
  • Normalized repository.url so npm and tooling resolve the repo cleanly.
  • Removed redundant next/server type shims; tests typecheck against real Next types.
  • Added a real runtime integration test driving a live upstream over fetch. Suite is now 71/71.
v2.2.02026-06-04Streaming passthrough
  • New stream option: true | "auto" | per-request — pipes SSE, NDJSON, and LLM token streams.
  • All guards run before the fetch; only content-type & cache-control are forwarded, Set-Cookie dropped.
  • Adds nosniff and X-Accel-Buffering: no for event streams. 67/67 tests pass.
v2.1.x2026-06-04Named routes, pluggable rate limit & CORS credentials
  • New routes option resolves server-defined destinations, eliminating client-driven SSRF.
  • RateLimitStore interface + InMemoryRateLimitStore for shared/global limits.
  • Opt-in corsCredentials, hardened against wildcard origins. Docs rewritten, all English.
v2.0.02026-06-04SSRF protection (breaking)
  • Added allowedHosts and allowPrivateHosts; absolute endpoints rejected (403) unless allowed.
  • Added timeoutMs (504 on timeout); internal errors no longer leak to the client.
  • Removed nextProxyHandlerAsync; preflight no longer reflects denied origins.