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.
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.
1# pnpm2pnpm add nextjs-proxy34# npm5npm install nextjs-proxy67# yarn8yarn add nextjs-proxyGetting 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.
1// app/api/proxy/route.ts2import { nextProxyHandler } from "nextjs-proxy"34export 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.
1// The client always POSTs a JSON body. Shape:2// { method, endpoint, data?, route? }3// Prefer { route } so the server owns the destination.45// 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})1112// 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
auth→ 401 Unauthorized (auth) on failure - 2
csrf→ 403 Forbidden (csrf/xss) on failure - 3
CORS origin (OPTIONS preflight + actual request)→ 403 Origin not allowed on failure - 4
inMemoryRate→ 429 Rate limit exceeded on failure - 5
rateLimit (external hook)→ 429 Rate limit exceeded on failure - 6
validate→ 401 Unauthorized on failure - 7
Named-route resolution + SSRF host check→ 400 / 403 on failure
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")), // 4015 csrf: (req) => req.headers.get("x-csrf") === process.env.CSRF, // 4036 // CORS origin check (allowOrigins) ............................ 4037 inMemoryRate: { windowMs: 60_000, max: 100 }, // 4298 rateLimit: async (req) => myRedisLimiter.check(req), // 4299 validate: async (req) => userCanAccess(req), // 40110 // SSRF host check on the resolved endpoint ................... 40311})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.
1// app/api/proxy/route.ts2export 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})1011// Client12await 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.
1export const POST = nextProxyHandler({2 // Allowlist of destination hosts for absolute endpoints.3 allowedHosts: ["api.stripe.com", "*.my-service.com"],45 // 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.
1export const POST = nextProxyHandler({2 // Required: explicit allowlist (never "*") when using credentials.3 allowOrigins: ["https://app.my-domain.com"],45 // 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.
1import { nextProxyHandler, InMemoryRateLimitStore } from "nextjs-proxy"23export 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.
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-like5})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.
1export const POST = nextProxyHandler({2 baseUrl: "https://api.my-service.com",34 transformRequest: ({ method, endpoint, data }) => ({5 method,6 endpoint,7 data: { ...data, source: "web" },8 }),910 transformResponse: (res) => ({ ok: true, data: res }),1112 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:
| Status | Body | When |
|---|---|---|
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–5xx | upstream body (passthrough) | successful proxy forwards upstream status & body |
1export const POST = nextProxyHandler({2 timeoutMs: 30_000, // aborts the upstream fetch; 0 disables. Timeout -> 5043 log: (event) => {4 // "request" | "response" | "error" — full detail stays server-side5 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.
1// nextjs-proxy is a ROUTE HANDLER, not middleware — it lives in2// app/api/proxy/route.ts. You can still gate that route from the3// framework's edge file if you want a coarse pre-filter.45// Next 13–15: middleware.ts | export middleware6// Next 16: proxy.ts | export proxy7export function proxy(req) {8 // optional: cheap allowlist / header check before the handler runs9}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.
1import {2 proxyFetch,3 useProxyFetch,4 ProxyFetchProvider,5} from "nextjs-proxy"67// 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
| Field | Type | Description |
|---|---|---|
ok | boolean | true when HTTP status is 2xx |
status | number | HTTP status code (200, 404, 500, etc.) |
data | T | undefined | Parsed response body (present when ok === true) |
error | ErrorInfo | undefined | Normalized error (present when ok === false) |
headers | Headers | undefined | Response headers from the proxy endpoint |
Error classification
| Type | When | Behavior |
|---|---|---|
server | HTTP 4xx / 5xx | Returned in response.error — does NOT throw |
network | DNS fail, CORS, network down | proxyFetch() throws TypeError |
timeout | AbortController timeout | proxyFetch() throws AbortError |
unknown | Unexpected error shape | proxyFetch() throws the raw error |
1import { proxyFetch } from "nextjs-proxy"23interface User {4 id: number5 name: string6}78// 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"1516if (res.ok) {17 console.log(res.data.name) // ✅ typed18} else {19 console.log(res.status, res.error)20}2122// POST with data:23const created = await proxyFetch({24 route: "users",25 method: "POST",26 data: { name: "Alice" },27 headers: { "X-Request-ID": "abc" },28})2930// 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
| Field | Type | Description |
|---|---|---|
data | T | undefined | Response data (present on success) |
error | ErrorInfo | undefined | Normalized error (present on failure) |
loading | boolean | true 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.
1import { useProxyFetch } from "nextjs-proxy"23function 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 })910 if (loading) return <div>Loading...</div>11 if (error) return <div>{error.message}</div>12 if (!data) return <div>No data</div>1314 return (15 <div>16 <p>{data.name}</p>17 <button onClick={refetch}>Refresh</button>18 </div>19 )20}2122// Polling — auto-refetch every 5 s:23function LiveNotifications() {24 const { data: notifications } = useProxyFetch({25 route: "notifications",26 refetchInterval: 5_000, // starts after first response27 })28 return <ul>{notifications?.map(n => <li key={n.id}>{n.message}</li>)}</ul>29}3031// 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
Per-call url option— highest priority — forces this URL for this call only - 2
Context URL (from ProxyFetchProvider)— used when no per-call url is given - 3
Default "/api/proxy"— used when no provider is present and no url is given
1import {2 ProxyFetchProvider,3 proxyFetch,4} from "nextjs-proxy"56// Wrap your app (or a subtree):7<ProxyFetchProvider url="/api/v2/proxy">8 <App />9</ProxyFetchProvider>1011// Any component inside the provider:12const res = await proxyFetch({ route: "user" })13// → uses "/api/v2/proxy"1415// Per-call url overrides context:16const res2 = await proxyFetch({17 route: "user",18 url: "/custom-proxy", // forces this URL19})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 | undefinedNamed, 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.
baseUrlstringPrefix used to resolve relative endpoints; its host is implicitly trusted.
allowedHostsstring | string[] | (url: URL, req) => booleanAllowlist 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 false ⇒ 401 Unauthorized.
rateLimit(req) => boolean | Promise<boolean>External rate-limit hook (e.g. backed by Redis). Returning false ⇒ 429 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) => booleanCORS 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) => unknownCustom 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> | voidReshape 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) => objectAdjust 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) => unknownSanitize the request body before transit (runs before maskSensitiveData).
maskSensitiveData(data: unknown) => unknownMask sensitive keys in the outbound request body before transit.
log(info: LogInfo) => voidReceive structured request / response / error events (ip, method, origin, endpoint, status, durationMs, payload).
monitor(req, res?) => voidSuspicious-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.
