Skip to content
Chapter 33Lesson 3

Rewrites and redirects in proxy.ts

How Next.js sends a request elsewhere, the visible redirect versus the invisible rewrite, where each rule belongs, and the safe post-login return that closes the open-redirect hole.

Your SaaS just shipped v2 of its billing surface. The old screen lived at /billing/manage; the new one lives at /settings/billing. The page is gone, but the old URL is not. It sits in months of receipt emails, in a few customers’ bookmarks, and in whatever Google indexed last quarter. When someone clicks it, three things have to happen: the browser needs to go to the new URL, search engines need to record that the page moved for good, and the user needs to land on the right screen without noticing anything broke.

Now consider a second, unrelated need. The same app serves multiple tenants on subdomains: acme.app.com and globex.app.com are two different customers’ workspaces. When Acme’s admin opens acme.app.com/dashboard, the server has to render their org’s dashboard, but the address bar must keep saying acme.app.com/dashboard. The user should never see the internal route that actually did the rendering.

These are two different jobs, and they have two different names. The first is a redirect: the URL the user sees genuinely changes. The browser navigates, history gets an entry, and a permanent redirect tells search engines the page has a new home. The second is a rewrite: the URL the user sees stays exactly the same while the server quietly renders a different route behind it. One is visible, the other is invisible, and confusing the two is a common way to ship an app that is subtly broken.

In the last lesson you built proxy.ts: the file that runs before the route, the matcher that controls which requests pay for it, and the three shapes a proxy can return, redirect, rewrite, and next. You named rewrites and redirects as one of the proxy’s legitimate jobs and moved on. You also left a deliberate gap: the auth gate redirected unauthenticated users to /sign-in?next=… with the next value passed straight through, unvalidated, flagged for this lesson. This is that lesson. By the end you’ll have three things. First, the clean distinction between the two operations. Second, a rule for where each one belongs, since the proxy is only one of the places a redirect can live, and it isn’t the default choice. Third, the two production patterns, subdomain rewrites and safe post-login redirects, each with the sharp edge that catches people who skip it. That last pattern closes the security hole for good.

The fastest way to keep redirect and rewrite straight is to watch one thing: the address bar. A redirect changes it; a rewrite leaves it alone. Everything else follows from that one difference.

A redirect is the proxy returning a 3xx response with a Location header. The browser reads that header and does something specific: it throws away the response and issues a brand-new request to the URL in Location. So a redirect is two round trips. The first request comes back as “go here instead,” and the second request is the browser actually going there. Because that second request is a real navigation, the address bar updates, a history entry is added, a bookmark would save the new URL, and a search engine treats a permanent redirect as the canonical replacement for the old one.

A rewrite is the proxy returning the content of a different internal route. The browser made one request and got one response back, with a normal 200 and a normal page. It never learns that a different route exists. There is one round trip, and the address bar stays exactly where it was. The user sees one URL; the server rendered another and never told the browser about the swap.

The one fact that separates them is how many times the browser talks to the server, and what it shows when it’s done. Here are the two flows side by side; flip between the tabs and follow the arrows.

%%{init: {'themeCSS': '.messageText, .messageText tspan { font-size: 20px !important; } .actor, .actor tspan { font-size: 18px !important; } .noteText, .noteText tspan { font-size: 17px !important; }'} }%%
sequenceDiagram
    participant B as Browser
    participant P as Proxy
    participant R as Route

    rect rgba(56, 189, 248, 0.12)
        Note over B,P: Round trip 1 — "go here instead"
        B->>P: GET /billing/manage
        P-->>B: 308 · Location: /settings/billing
    end

    Note over B: Address bar now shows<br/>/settings/billing

    rect rgba(34, 197, 94, 0.12)
        Note over B,R: Round trip 2 — the browser actually goes there
        B->>P: GET /settings/billing
        P->>R: pass through
        R-->>B: 200
    end
Two round trips. The browser is told to go elsewhere, asks again, and the address bar changes to the destination /settings/billing and stays there.

Neither operation is the “better” one; they answer different product questions. Reach for a redirect when the URL itself should change: a page was renamed, a feature was deprecated, or a logged-in user shouldn’t sit on /login. Reach for a rewrite when the implementation moved but the user shouldn’t have to care: multi-tenancy, an internal restructuring, or an A/B variant served from a different path. The question to ask is never “which is faster,” it’s “should the user’s URL change?”

In the lifecycle diagram from the last lesson, these were two of the three terminals a proxy can end on: NextResponse.redirect() and NextResponse.rewrite(). The third, NextResponse.next(), is the pass-through, which lets the request continue to its route untouched. You already know that API surface; this lesson is about when to reach for each, not how to call it.

A redirect carries a status code, and the code is not a formality: it tells the browser and every search engine crawler how long to believe the redirect. The right code makes the move clean; the wrong one creates a problem that outlives the code that caused it.

NextResponse.redirect(url) defaults to 307, a temporary redirect. Pass a second argument to promote it to 308, a permanent one.

NextResponse.redirect(new URL('/settings/billing', request.url)); // 307, temporary
NextResponse.redirect(new URL('/settings/billing', request.url), 308); // 308, permanent

The difference is what happens downstream. A 308 is a promise that the move is forever. Search engines update their index to point at the new URL and forward the old page’s link equity to it; browsers are allowed to cache the redirect and stop asking the server about the old URL at all. A 307 says the opposite: this is temporary, so keep treating the old URL as the real one. For the billing rename, a genuine and permanent move, you reach for 308. For a logged-in user bounced off /login, you reach for 307, because that redirect isn’t a permanent property of the URL; it’s a temporary fact about this user right now. Tomorrow they’re logged out, and /login is exactly where they should be.

You may have seen 301 and 302 in older code or in interview questions. Don’t use them. They predate the method-preserving redirects, and many HTTP clients silently rewrite a POST into a GET when they follow a 301 or 302, which turns a form submission into a broken read. 307 and 308 preserve the request method exactly. Modern code uses the new pair and nothing else.

One detail catches people out. A permanent redirect is sticky in a way a temporary one is not. Once a browser has cached your 308 and a search engine has reindexed around it, you cannot easily un-tell them. Remove the redirect rule from your code and the cached 308 lives on in browsers that already saw it, sending users to a URL that may no longer make sense. That asymmetry is the whole point: a wrong 307 is a minor inefficiency you fix by editing one line, while a wrong 308 persists long after the rule is gone. So when you are not certain a move is permanent, under-commit: ship the 307, and promote it to 308 only once you’re sure.

Run your instincts through a few quick checks.

Each statement is about redirect status codes. Mark each statement True or False.

Renaming /account to /settings permanently is a job for a 308.

The page genuinely moved for good — a permanent redirect tells search engines to reindex and forwards the old page’s link equity to the new URL.

A 302 redirect is guaranteed to preserve a POST request’s method.

It isn’t — many clients silently downgrade the POST to a GET on a 301 or 302. That’s exactly why modern code uses 307/308, which preserve the method.

Calling NextResponse.redirect(url) with no second argument sends a 308.

The default is 307 (temporary). You have to pass 308 explicitly to make it permanent.

A logged-in user bounced away from /login should get a 307, not a 308.

The redirect depends on the user’s session, not on the URL itself. It’s temporary by nature, so 307. A 308 would tell browsers to cache “never visit /login” — wrong for a user who later logs out.

When you’re unsure whether a move is permanent, a 308 is the safe default.

It’s the riskier default. A wrong 308 gets cached and indexed and is hard to undo. Under-commit with 307 until you’re certain.

Where the rule belongs: proxy, config, or redirect()

Section titled “Where the rule belongs: proxy, config, or redirect()”

You now know what a redirect is. The harder question, the one that separates a working app from a fast, maintainable one, is where the rule that issues it should live. By this point in the course you have met redirects in more than one place, and you’re about to meet another. A redirect in the wrong place is at best a performance bug and at worst a maintenance trap that’s painful to find later. Here is how an experienced engineer sorts them.

There are three homes, and the choice between them comes down to two questions asked in a specific order.

The first question is about the request. Some redirects are always true for everyone: /billing/manage goes to /settings/billing no matter who asks, what cookies they carry, or where they’re coming from. A rule like that doesn’t need to look at the request at all, which means it doesn’t belong in the proxy. It belongs in next.config.ts, in a redirects() block, where the platform can apply it at the CDN edge with zero function invocation: no proxy.ts runs, no server wakes up, and the redirect is served from the edge faster and cheaper than your code ever could. You’ll build that config in the next chapter. For now the rule is simply this: if the redirect never reads the request, it doesn’t go in the proxy.

Other redirects do read the request. Whether to bounce a user off /login depends on their session cookie. Which A/B bucket to route into depends on a cookie the proxy set. Which locale subpath to serve depends on a header. Only code that runs at request time can see any of that, and that’s exactly what proxy.ts is: code that runs before the route, with the whole request in hand. The cost, as you saw last lesson, is that every request the matcher selects pays the proxy round trip. So this is the home for request-conditional redirects, and the matcher stays tight.

There is a second question, though, because not every request-dependent redirect happens before the route. Think about the redirect after a Server Action: the user submits a “new invoice” form, the action writes the row, and then sends them to that invoice’s page. That decision isn’t made at the network boundary; application code makes it in the middle of doing work, once the work succeeds. That’s redirect() and permanentRedirect() from next/navigation, which you met back in routing, along with notFound() for when a route looks up a resource and finds it gone. The proxy runs before the route; redirect() runs during or after it. Different moment, different tool.

So the test is two questions, in order: Does the redirect depend on the incoming request? And does it need to happen before the route renders? Walk a few real cases through them.

Where does this redirect belong?

The order of those two questions matters. Ask request-dependence first, because it splits the static rules off to the config, where they’re cheapest. Only the rules that survive that question reach the second one, where timing decides between the proxy and redirect(). Static-and-known goes in the config; request-conditional goes in the proxy; after-an-action goes in redirect(). That is the rule to leave this section with.

Here is the first production pattern, and the concrete payoff for “rewrite is the invisible swap.” Your app serves multiple tenants on subdomains: acme.app.com/dashboard should serve Acme’s org-scoped dashboard while the address bar keeps saying acme.app.com/dashboard. The user never sees an org id in the URL, because the subdomain is the org. Internally, though, you want one set of routes parameterized by org, not a copy per customer. A rewrite bridges the two: read the subdomain, render the parameterized route, and leave the URL alone.

Let’s build it up one step at a time.

export default function proxy(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(
new URL(`/orgs/${sub}/dashboard`, request.url),
);
}
return NextResponse.next();
}

Pull the host off the request, then carve out the tenant. acme.app.com gives us acme. The split(':')[0] strips the port first, because in development the host is acme.localhost:3000 and the port rides along until you cut it. Forget that step and the lookup fails only on your machine, which is a hard bug to track down. (The optional chain keeps TypeScript’s noUncheckedIndexedAccess happy when a split yields nothing.)

export default function proxy(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(
new URL(`/orgs/${sub}/dashboard`, request.url),
);
}
return NextResponse.next();
}

Validate the subdomain, but do it cheaply. The proxy runs on every matched request, so this is the one place you must not put a database query. isKnownOrg is a stand-in for a cached tenancy check, such as an in-memory set or an edge KV read, never a round trip to Postgres per request. The real cached lookup is org-and-tenancy territory you’ll build later.

export default function proxy(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(
new URL(`/orgs/${sub}/dashboard`, request.url),
);
}
return NextResponse.next();
}

Rewrite to the internal route. The user keeps seeing acme.app.com/dashboard while the /orgs/acme/dashboard route renders behind it. Reach for NextResponse.rewrite() rather than a hand-rolled fetch: the helper propagates the headers React needs for client navigations to keep working, and a raw fetch silently drops them.

export default function proxy(request: NextRequest) {
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(
new URL(`/orgs/${sub}/dashboard`, request.url),
);
}
return NextResponse.next();
}

And if the subdomain isn’t a known org, fall through with next(). As last lesson drilled, every branch returns and nothing passes through implicitly.

1 / 1

There are a couple of deliberate simplifications in there, so you don’t mistake the teaching shape for the production one. The subdomain parse, splitting on . and grabbing the first segment, is naive: a real app has to handle the apex domain (app.com with no subdomain) and the www prefix, and you’ll do that when you build the tenancy layer properly. And isKnownOrg stands in for a cached lookup, not the bare check it looks like. What matters here is the mechanic: read the host, validate cheaply, and rewrite.

The rewrite lands on a route file at app/orgs/[org]/dashboard/page.tsx. The [org] segment is a dynamic param, the same kind you met in the routing chapter, and the route reads params.org (which here holds the subdomain) to scope its queries to that org’s data. That same [org] route also works for path-based tenancy, where the URL is app.com/orgs/acme/dashboard and carries the org segment openly. In that case no rewrite is needed, because the URL already says which org. One set of routes, two ways to reach it. (The exact async shape of params, which is a Promise in Next.js 16, is the next lesson; for now, just know the route reads params.org.)

There is one sharp edge that belongs to rewrites specifically, and it’s worth seeing clearly because it doesn’t exist for redirects. A rewrite hands the request to an internal route, but that internal route is itself a path, and if it also matches your proxy’s matcher, the proxy runs again on the rewritten request. If the same condition still holds, it rewrites again, and again. Nothing breaks loudly; the request just keeps re-entering the proxy until the platform gives up and returns a server error on the path you were trying to serve.

There are two ways out. The clean one is to exclude the rewrite target from the matcher. The internal /orgs/* paths don’t need the proxy’s auth gate or its rewrite logic, so keep them out of the matcher entirely and the rewrite can’t loop back in. That’s what you’ll do in the final file below. When exclusion isn’t possible, say the target legitimately needs the proxy for something else, the fallback is a sentinel header: set x-rewritten: 1 on the rewrite, and short-circuit with NextResponse.next() at the top of the proxy whenever that header is already present on entry. The header survives the internal hop, so the second pass sees it and stops.

This is a small technique, but a useful one, and it leans on the cookie-timing model from earlier in this chapter. Sometimes you want to record a choice and send the user onward in a single reply: they pick a locale on a splash screen, and you both remember the choice and move them to the app.

You do it by setting the cookie on the redirect response before you return it:

const response = NextResponse.redirect(new URL('/dashboard', request.url));
response.cookies.set('locale', 'es');
return response;

The catch is the timing, and it’s the same rule you’ve already met: a cookie you set is an instruction to the browser, and the browser only sends it back on the next request. So the cookie you set here is not readable while you handle this pass; it shows up on the request that follows. For a redirect that’s usually fine, because the redirect is that next request: the browser navigates to /dashboard carrying the new locale cookie, and the route there reads it normally. Just don’t expect to set a cookie and read it back within the same proxy run. That request already left the browser with the old cookies, and nothing you do on the response changes what already arrived.

The post-login return and the open-redirect hole

Section titled “The post-login return and the open-redirect hole”

This section closes the debt the last lesson left open, and the stakes are high: it’s a real, exploited class of vulnerability. The fix is a single small helper you’ll reuse everywhere.

Start with why the pattern exists. When the auth gate bounces an unauthenticated user, it doesn’t just send them to /sign-in; it remembers where they were trying to go, so it can return them there after they log in. You saw the shape last lesson: the proxy reads the path they wanted and tucks it into the sign-in URL as a query param.

const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', request.nextUrl.pathname);
return NextResponse.redirect(signIn);

So far, this is harmless. The trap springs later, when the login flow reads that next value and redirects to it. Walk the data: next came from the URL, which means it came from whoever crafted the link. An attacker sends a victim a link to https://your-app.com/sign-in?next=https://evil.com. The victim sees your real, trusted domain in the URL and logs in normally, and your login flow obediently redirects them to the attacker’s site, which is a pixel-perfect clone of your dashboard asking them to “confirm your password.” This is an open redirect , the classic way a trusted login page gets turned into a phishing tool. It is exactly why the last lesson left its next unvalidated and flagged it for here: writing the value down was safe, but redirecting to it blindly is not.

The fix is a rule: never redirect to a user-supplied URL without first proving it’s a same-origin path. Concretely, you accept a value only if it starts with a single / and isn’t a protocol-relative // or a full protocol:// URL. Anything that could send the browser off your origin gets rejected and replaced with a safe default. In this course that rule lives in one helper, safeNext, in lib/redirects.ts, and the rule is absolute: you never pass searchParams.get('next') straight into a redirect. It always goes through the helper.

const next = signIn.searchParams.get('next');
return NextResponse.redirect(new URL(next, request.url));

Open redirect. next came straight from the URL. An attacker sets ?next=https://evil.com and your own login page launders their phishing link: the victim trusts your domain, logs in, and lands on the attacker’s clone.

Here is the helper itself. It’s small on purpose: the whole point of a security primitive is that it’s easy to read, easy to audit, and used in exactly one shape everywhere.

lib/redirects.ts
export function safeNext(next: string | null, fallback = '/dashboard'): string {
if (!next || !next.startsWith('/') || next.startsWith('//')) return fallback;
return next;
}

Three checks, three holes closed. An empty or missing value falls back. A value that doesn’t start with / is rejected, which kills https://evil.com and oddities like javascript:alert(1). And the // check is the subtle one: a protocol-relative URL like //evil.com does start with a single /, so the first check waves it through, but the browser would resolve it to https://evil.com and send your user off-origin. Catching it is the difference between a helper that looks safe and one that is.

One honest caveat, because security primitives age and you should know where this one’s edge is. The string checks above are the readable teaching shape, and they hold up well, but they can miss adversarial encodings like a backslash variant (/\evil.com) or percent-encoded slashes that some browsers normalize after your check runs. The hardened version parses the candidate with new URL(next, origin) and compares the resolved origin against your own, rejecting on any mismatch, rather than reasoning about the raw string. Reach for that form once this matters; for now, know that startsWith is the clear version of the rule, not the last word on it.

Test the rule on a handful of values.

Which of these ?next= values does safeNext return unchanged — i.e. accepts as a safe redirect target? Select all that apply.

/dashboard/invoices
https://evil.com
//evil.com
/settings?tab=billing
javascript:alert(1)

Now you can assemble the whole file. This is the same proxy.ts you started last lesson, the matcher and the auth gate, now grown to do everything this chapter asks of it: the legacy billing redirect, the subdomain rewrite, and the auth gate with its next value finally validated. It does the three jobs the chapter set out to teach, and it stays under fifty lines.

The order of the body matters, and it’s the order an experienced engineer would write it in: cheap, specific rules first, and the auth gate last. The legacy redirect is a single path equality check, the cheapest, so it goes on top. The subdomain rewrite comes next, and it returns, so a request that gets rewritten to /orgs/* never reaches the gate, because that route runs its own authoritative session check (presence here, real check there). The auth gate runs last so it only handles what’s left: requests no earlier rule claimed, where the path it captures into next is the one the user actually asked for. Throughout, every branch returns, the same reflex from last lesson.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

Start at the matcher, because it shapes everything above it. It excludes assets and /api like last lesson, but note that it now also excludes orgs. That’s the loop fix: /orgs/* is the rewrite’s target, and keeping it out of the matcher means the rewritten request can’t re-enter the proxy and loop.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

The legacy redirect. /billing/manage moved permanently to /settings/billing, so it’s a 308. It’s request-independent, so a purist would put it in next.config.ts, but it lives here, beside the app’s other URL shaping, because that’s where the team keeps URL logic. The config is cheaper for truly static rules; you’ll see that trade in the next chapter.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

The subdomain rewrite. Read the host, strip the port, and if it’s a known org, rewrite to the org route invisibly, with the address bar unchanged. isKnownOrg is the cheap cached check, never a database call.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

The auth gate’s presence check, unchanged from last lesson: getSessionCookie with the project’s SESSION_COOKIE_PREFIX. Presence here, real check there: the proxy only asks whether a session cookie exists, and the route does the authoritative verification.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

Here is the debt being paid. The path going into next now passes through safeNext before it’s written. The hole the last lesson left open is closed: no user-controlled value reaches a redirect target unvalidated.

proxy.ts
import { getSessionCookie } from 'better-auth/cookies';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
import { safeNext } from '@/lib/redirects';
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
if (pathname === '/billing/manage') {
return NextResponse.redirect(new URL('/settings/billing', request.url), 308);
}
const host = request.headers.get('host') ?? '';
const sub = host.split(':')[0]?.split('.')[0] ?? '';
if (isKnownOrg(sub)) {
return NextResponse.rewrite(new URL(`/orgs/${sub}${pathname}`, request.url));
}
const hasSession = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX });
if (!hasSession) {
const signIn = new URL('/sign-in', request.url);
signIn.searchParams.set('next', safeNext(pathname));
return NextResponse.redirect(signIn);
}
return NextResponse.next();
}
export const config = {
matcher: '/((?!api|_next/static|_next/image|favicon.ico|orgs).*)',
};

And the final return NextResponse.next(). Every branch above returned; this is the pass-through for everything that matched the matcher but tripped none of the rules. No implicit fall-through, ever.

1 / 1

That’s the production slot, intentionally hollow in the right places. The real session machinery behind getSessionCookie and SESSION_COOKIE_PREFIX arrives when you build authentication; the cached tenancy lookup behind isKnownOrg arrives with multi-tenancy; and the static-redirect alternative for the legacy rule is the next chapter. What you have now is the complete shape of a request gate, with cheap exclusions, an invisible rewrite, a visible redirect, and a validated bounce, plus the judgment to know which job each line is doing.