Next 16 renamed middleware to proxy.ts, and ours does three unrelated jobs
We are on Next 16.2.4, where the file that used to be middleware.ts is now proxy.ts. The rename is the whole migration: same default export, same config with the same matcher, same NextRequest and NextResponse. The funct
We are on Next 16.2.4, where the file that used to be middleware.ts is now
proxy.ts. The rename is the whole migration: same default export, same config
with the same matcher, same NextRequest and NextResponse. The function inside
ours is still literally called middleware, because nothing made me rename it
and the word describes what it does better than "proxy" does.
It is 195 lines and it does four things that have nothing to do with each other.
That is not an accident of neglect. It is the only code that runs before every
response, so anything that must be decided before the response gets decided here,
and "related" is not one of the criteria.
One: what is public, expressed as two strings
const PROTECTED_PREFIXES = ['/dashboard', '/exercises'];
The auth model is public by default. Everything that is not under those two
prefixes returns on the next line, so adding a new marketing page, provider hub,
test format page or blog post needs no change here at all. Given that the sitemap lists 375
public URLs and the signed-in app is those two prefixes, an allowlist of public
routes would have been the wrong way round.
This is explicitly a performance and UX gate rather than a security boundary.
Every page under those prefixes also checks the session itself and redirects, and
every API route enforces its own auth. If the middleware were deleted tomorrow,
nothing would leak. Pages would just get slower and the device limit would stop
being enforced.
Two: a cookie JavaScript is allowed to read
Open any page on https://cogniprep.app and run this in the console:
document.cookie;
// "cp_country=NL"
One cookie, two letters. Every other cookie we set is httpOnly, so that is the
complete list of what page JavaScript can see. The exception is deliberate and
the comment in the file says why: the pages that show prices are statically
rendered, so they cannot read x-vercel-ip-country themselves without becoming
per-request. Handing the country to the client as a cookie lets a blocking script
in the root layout choose the currency before anything is painted, and leaves the
cached HTML alone.
const country = request.headers.get('x-vercel-ip-country');
if (!country) return response;
response.cookies.set(COUNTRY_COOKIE, country.slice(0, 2).toUpperCase(), {
...LONG_LIVED_COOKIE_OPTIONS,
httpOnly: false,
maxAge: 60 * 60 * 24 * 30,
});
Three decisions in that block.
It is only ever written from the platform header, never from anything the client
sends. It carries a two letter country code and nothing else, no identifier and
no session material, so there is nothing in it worth protecting from the page's
own script.
It is re-set on every request rather than only when missing, so a visitor who
moves country stops seeing the currency of the country they left.
And its life is 30 days, against a year for the device token, because this is a
display preference and a stale one is refreshed by the next visit anyway.
You can watch it work: the cookie above was NL when I checked, and the
prices on https://cogniprep.app/pricing render in euros.
Three: crawlers stop at the door
On a protected route, before any session work:
const isBot = isBotUserAgent(request.headers.get('user-agent') || '');
if (isBot) return NextResponse.next();
A crawler only reaches /dashboard by following a link into the app, and the page
will redirect it to /login anyway, so there is no reason to spend a session
refresh and a device-slot write on it.
The pattern lives in its own tiny module, shared with the server side currency
resolver, which quotes GBP to crawlers so search engines index the currency we
actually charge in rather than the currency of whichever datacentre fetched the
page. One definition, two callers, and the second caller is the reason it is not
inlined here:
export const BOT_PATTERN =
/vercel|bot|crawler|spider|googlebot|bingbot|slackbot|twitterbot|facebookexternalhit|linkedinbot|whatsapp|headless/i;
googlebot and bingbot are redundant against bot and I have left them in
because the list doubles as documentation. vercel is there for the platform's
own screenshot and preview fetchers, and headless catches a lot of automation
that is honest about itself. It is a user agent string check, so it is advice
rather than enforcement, which is fine for something whose only power is to skip
work.
Four: the await that stopped stranding devices
The device limit is two devices per account, resolved in the middleware because
the answer has to exist before the page renders.
const slot = await ensureDeviceSlot(user.id, candidateToken, deviceFingerprint, userAgent);
if (!slot.allowed) {
return NextResponse.redirect(new URL('/device-limit', request.url));
}
That await is the bug fix. The write used to be fire and forget, because why
block a response on a bookkeeping row. The answer is that the serverless instance
can be frozen the moment the response is sent, so the write could be dropped
after the cookie naming it had already gone out. The device then held a token the
database had never seen, and once its two siblings held both slots, it was walled
out permanently with no way to recover.
The matching rule is on the refusal path: no device cookie is written when the
slot is denied, because an unregistered token sitting in a cookie is exactly the
state that stranded devices in the first place.
And when a device loses its cookie, ensureDeviceSlot can return a token that
differs from the candidate, which is how it gets stitched back to the row it
already owns instead of consuming the second slot:
if (slot.deviceToken !== existingToken) {
response.cookies.set(DEVICE_TOKEN_COOKIE, slot.deviceToken, LONG_LIVED_COOKIE_OPTIONS);
}
The comment I am proudest of is about code that is not there
There used to be an escape hatch letting an over-limit device still reach
/device-limit and /auth/*. It was dead: both live outside
PROTECTED_PREFIXES, so neither can reach that far, and being outside the list
is a stronger exemption than the hatch granted. Deleting it left a 12 line
comment that ends, in capitals, with an instruction to put it back if anyone ever
adds /device-limit or /auth to the protected list. Without it, an over-limit
device asking for the wall page would be redirected to the wall page forever, and
an OAuth callback would be walled out before it could register a device.
Dead code should be deleted. The reason it was safe to delete usually should not
be.
Two more small things
The matcher excludes ingest, which is the PostHog reverse proxy path. Analytics
ingestion has no business paying for a session refresh, and the rewrite that
serves it does not need a cookie decision.
The catch block imports Sentry dynamically, in production only, so the error path
does not pull the SDK into the middleware bundle for the sake of a failure that
normally never happens. The capture is tagged middleware: 'proxy', which is now
the only place in the codebase where both names appear together.
If you want to see the parts of this that are visible from outside: run
document.cookie on https://cogniprep.app/games and count the entries, then look
at the currency on https://cogniprep.app/pricing. One of those two letters picked
it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.