Our auth boundary is an allowlist. src/proxy.ts (Next 16 renamed middleware.ts to proxy.ts) refreshes the Supabase session and sends signed-out visitors to the login page unless the path they asked for is public:
if (!data?.claims && !isPublicPath(path)) {
const url = request.nextUrl.clone();
url.pathname = "/login";
url.search = `?next=${encodeURIComponent(path + request.nextUrl.search)}`;
return NextResponse.redirect(url);
}
isPublicPath is a set of exact paths plus a few prefixes: /pricing, /glossary, /guides/, /tools/, and so on. That function is the one place that knows what is open to the world on Nakodo, and it is written in the vocabulary of pages, because pages are what I think about.
The browser does not only ask for pages.
The same page, three URLs
A full document load of our pricing page is /pricing. A client-side navigation to it fetches the React Server Components payload instead, and a segment prefetch fetches less than that. The Next docs shipped in the package spell out the scheme:
- Full page RSC:
/my/page.rscreturns the RSC payload for the entire page- Segment RSC:
/my/page.segments/path/to/segment.segment.rscreturns the RSC payload for a specific segment
All three are live URLs you can curl. Different content types, same page:
$ curl -sI https://nakodo.app/pricing | grep -i "^content-type"
content-type: text/html; charset=utf-8
$ curl -sI https://nakodo.app/pricing.rsc | grep -i -E "^content-type|^x-matched-path"
content-type: text/x-component
x-matched-path: /pricing.rsc
$ curl -s -o /dev/null -w "%{http_code}\n" https://nakodo.app/pricing.segments/_tree.segment.rsc
200
Now ask the obvious question. isPublicPath("/pricing") is true. What does isPublicPath("/pricing.segments/_tree.segment") return?
False. It is not in the exact set, and the prefix /guides/ style entries all end in a slash, deliberately, so that /guidesx cannot sneak through. A path that is the page name with a suffix glued on is not public as far as that function is concerned.
Which form reaches the proxy, as sent?
This is the part worth knowing, and the live site answers it without any instrumentation. Ask for two private pages, one in each form, and read the next parameter our own code wrote:
$ curl -sI https://nakodo.app/campaigns.rsc | grep -i "^location"
location: /login?next=%2Fcampaigns
$ curl -sI https://nakodo.app/settings.segments/_tree.segment.rsc | grep -i "^location"
location: /login?next=%2Fsettings.segments%2F_tree.segment
That next value is request.nextUrl.pathname as the proxy saw it. So:
-
.rscis stripped before the proxy runs. The proxy sees/campaigns. -
.segments/....segmentis not stripped. The proxy sees/settings.segments/_tree.segment, suffix and all.
Next normalizes the internal data URL back to the route path by default, which is what skipProxyUrlNormalize turns off. The normalization clearly covers one of these two shapes and not the other.
Which means a segment prefetch of a public page, left to itself, hits the allowlist as a path the allowlist has never heard of, and gets a 307 to the login page.
The matcher is what saves it, and it has to be a literal
It does not get a 307, because the proxy never runs on it:
$ curl -s -o /dev/null -w "%{http_code}\n" https://nakodo.app/guides.segments/_tree.segment.rsc
200
The config.matcher excludes it. Here is ours, all 411 characters of one string:
matcher: [
"/((?!_next/static|_next/image|api/cron|api/stripe|api/email|api/campaigns/[^/]+/changes$|$|index(?:\\.rsc|\\.segments/.+)$|(?:pricing|privacy|terms|about|how-it-works|glossary|unsubscribe|opengraph-image)(?:\\.rsc|\\.segments/.+)?$|(?:guides|blog|youtubers|tiktokers|instagrammers|alternatives)(?:/|(?:\\.rsc|\\.segments/.+)?$)|(?:for|tools|og|o)/|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|txt|xml|webmanifest)$).*)",
],
Nobody wants that in their repo. It exists in that shape because of one line in the docs:
The
matchervalues need to be constants so they can be statically analyzed at build-time. Dynamic values such as variables will be ignored.
So the list of public page families cannot be imported here. It is a literal, which means the one list that matters is written twice, in two notations, and the copy a human maintains is the TypeScript one. The (?:\\.rsc|\\.segments/.+)?$ tail repeated through it is the prefetch forms of each family.
Note what the exclusion buys. For a public page it is not correctness, it is a saved session refresh: a public page that falls through to the proxy still renders, it just pays for a Supabase call it had no use for. For the .segments form it is correctness, because without the exclusion the allowlist would not recognise the URL.
Two things I found by poking at it
One half of each alternation appears to be dead weight. If the matcher were tested against the raw path, /index.rsc would be excluded by the index(?:\\.rsc|...) branch and the proxy would never run on it. It runs:
$ curl -sI https://nakodo.app/index.rsc | grep -i "^location"
location: /login?next=%2Findex
The proxy saw /index, so the matcher was tested against the already-normalized path, which means every \\.rsc branch in that literal is belt and braces and only the \\.segments branches do work. The docs agree, in passing: "the matcher config still matches the destination URL either way". I am leaving the .rsc branches in anyway. They cost nothing, they document the three forms a page has, and an undocumented normalization is not something I want load-bearing.
/index is a phantom path. The home page's payload is /.rsc, not /index.rsc, so that redirect above is pointing a visitor at a page that does not exist. Harmless, since nothing generates that URL, and a good reminder that a ?next= parameter built from a path is only as sane as the path.
The trap I was one refactor away from
The docs bury the real danger in a "good to know":
Server Functions are not separate routes in this chain. They are handled as POST requests to the route where they are used, so a Proxy matcher that excludes a path will also skip Server Function calls on that path.
Every path you exclude for performance is a path whose Server Actions also stop passing through your optimistic auth check. Ours are fine: the excluded pages are the marketing site and it has no actions, and how Nakodo works is a static piece of content with nothing to submit. The real defence is the boring one, and it is the only reason I am comfortable with a 411-character regex deciding what runs: every Server Action fetches the user itself and filters every query by their id. If the proxy vanished, URLs would get rude, not leaky.
What I would tell somebody starting this
Write the allowlist as data and in page vocabulary, then go and find out what URLs your framework actually requests for those pages, because in the App Router a page is at least three of them. The cheapest way to find out is the one above: a private path, one curl per form, and read the redirect your own code wrote.
Top comments (1)
Great write-up! One thing I enjoy about your articles is that they start with a very specific bug and end up teaching a broader lesson about how web frameworks actually behave. The idea that a prefetch request can take a different path through the application than a normal navigation is easy to overlook, especially when auth logic is involved. Itβs a good reminder that "works when I click it" doesnβt always mean "works when the framework touches it first." Thanks for sharing another real-world debugging story rather than a simplified example. ππ