Google indexed /Games, a URL that 404s, so our proxy now lowercases the path before anything else
URLs are case-sensitive and hostnames are not, which is one of those facts everybody knows and nobody's router remembers. Every public route on our site is a lowercase folder, so /Games is not a sloppy version of /games,
URLs are case-sensitive and hostnames are not, which is one of those facts everybody knows and nobody's router remembers. Every public route on our site is a lowercase folder, so /Games is not a sloppy version of /games, it is a different URL, and it was a 404.
It was also, according to Search Console, a URL Google had indexed for a real query. That is the entire motivation, and it is written down where the fix lives:
/**
* Every public route is lowercase, but a link to `/Games` or `/GAMES/pymetrics`
* either 404s or renders the wrong page, and Google had indexed `/Games` for a
* real query. A single 308 to the lowercase path keeps that link equity.
*/
function lowercaseRedirect(request: NextRequest): NextResponse | null {
const { pathname } = request.nextUrl;
if (request.method !== 'GET' && request.method !== 'HEAD') return null;
if (pathname === pathname.toLowerCase()) return null;
if (hasPrefix(pathname.toLowerCase(), CASE_SENSITIVE_PREFIXES)) return null;
const url = request.nextUrl.clone();
url.pathname = pathname.toLowerCase();
return NextResponse.redirect(url, 308);
}
Twelve lines, and three of them are refusals. Those are the interesting part.
The sections that must not be lowercased
// Sections whose dynamic segments can carry case-sensitive ids or tokens, so
// their paths are never lowercased.
const CASE_SENSITIVE_PREFIXES = [
'/api',
'/webhook',
'/auth',
'/dashboard',
'/exercises',
'/dev',
'/admin',
'/affiliate',
];
A blanket lowercase rule is a data-destroying rule. Our calendar feeds, our auth confirm links and our support threads all put a mixed-case identifier in the path, and toLowerCase() on one of those does not fail loudly: it produces a plausible URL that resolves to nothing, and the user sees a broken link rather than an error worth reporting.
So the eight prefixes are exempt, and the exemption test lowercases first:
if (hasPrefix(pathname.toLowerCase(), CASE_SENSITIVE_PREFIXES)) return null;
/API/calendar/feed/AbC123.ics is as exempt as /api/.... Testing the raw pathname against lowercase prefixes would have let a mixed-case section name through the exemption and then mangled the token after it, which is the one failure mode in here that would have been genuinely hard to debug from a bug report.
Why GET and HEAD only
A 308 is a permanent redirect that preserves the method and the body, which is exactly what you want for canonicalising a link somebody typed, and exactly what you do not want for a form submission. The guard keeps the rule to navigations:
it('does not redirect a POST', async () => {
const response = await middleware(
new NextRequest(`${BASE}/Games`, { method: 'POST', headers: { 'user-agent': HUMAN_UA } })
);
expect(response.status).not.toBe(308);
});
If something in our own app is posting to a mixed-case URL, that is a bug at the call site, and a silent redirect is how you keep it for a year.
The status code is also explicit. NextResponse.redirect defaults to 307, which is the temporary twin of 308. A canonicalisation is not temporary, and the whole point of the exercise is to pass link equity from the URL that got indexed to the URL that exists, so the second argument is not optional in spirit.
It is the first thing the middleware does
export default async function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname;
const lowercased = lowercaseRedirect(request);
if (lowercased) return lowercased;
Ahead of the internal rendering-language header, ahead of the maintenance check, ahead of the public-route branch that serves most of the site. A request that is going to be redirected should not be setting cookies or touching a session on the way out, and a normalisation that runs after any of that has to be re-reasoned about every time something new is added above it.
It also does not need to know whether the destination exists. The function is a pure string operation: lowercase the path, keep everything else. /employers/Barclays-X gets a 308 to /employers/barclays-x, which then 404s, and that is correct behaviour rather than a gap. Teaching the middleware which slugs exist would mean importing the registries into the edge bundle to make a 404 marginally prettier.
One more thing it never sees: assets. The matcher already excludes _next/static, _next/image, the metadata routes and a list of static file extensions, so no chunk or image URL reaches the function.
The whole test block is seven cases: three redirects, three untouched case-sensitive sections and the POST.
See it
Try these with curl -I, or just watch the address bar:
-
cogniprep.app/Games gives
308to/games. -
/GAMES/pymetrics?ref=xgives308to/games/pymetrics?ref=x. The query string survives untouched, including its case, because onlyurl.pathnameis replaced on the clone. That matters here:?ref=is how launch directories link to us. -
/Employers/Barclaysgives308to/employers/barclays, a real page. -
/De/Aongives308to/de/aon, which is our German page for that provider. -
/api/Healthand/Dashboardgive404, not a redirect, because both sit under an exempt prefix. The 404 is the honest answer there: nothing links to those shapes, and the alternative risks a lowercased token.
Then pick any page you like and type its path in the wrong case. If it is a public page, you will land on it.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.