Our next.config.mjs has a headers() function with two entries in it. The first sets six headers on every path. The second sets one cache header on /static/:path*.
There is no /static directory in this project. There never was.
I went through all seven header rules against what pub-trivia.app actually answers today, and the dead rule turned out to be the interesting one.
The six that work
{
source: '/:path*',
headers: [
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{ key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'SAMEORIGIN' },
{ key: 'Referrer-Policy', value: 'origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
]
}
/:path* means every path, and it means it literally. Check any page:
curl -sI https://pub-trivia.app/pricing
All six come back. So do they on a JavaScript chunk under /_next/static/, and on a 307, and on a 404. The one header that is missing everywhere is x-powered-by, because poweredByHeader: false is set a few lines above. compress: true is set too, and on Vercel it is decoration: the edge decides the encoding, not the Node server.
The seventh rule, and what it is actually doing
{
source: '/static/:path*',
headers: [
{ key: 'Cache-Control', value: 'public, max-age=31536000, immutable' }
]
}
That is the standard "assets are content hashed, cache them forever" rule. It is pointed at a directory that does not exist. Our public/ folder holds six files: three WebP screenshots and three SVG logos. Nothing is served from /static.
A header rule does not need a file to match, though. It matches a request. Try one:
curl -sI https://pub-trivia.app/static/anything.css
HTTP/2 307
cache-control: public, max-age=31536000, immutable
location: /login?next=%2Fstatic%2Fanything.css
The path is not one of our public prefixes, so the auth gate bounces it to the login form (that behaviour is its own story, which I wrote about a few days ago). The header rule had already run. So the only thing that one-year immutable cache applies to is a redirect to our sign-in page.
A 307 with an explicit Cache-Control is cacheable, which is the part worth caring about. It is harmless while /static is empty. The day somebody adds a public/static/ folder, every visitor who happened to probe that path once gets a year-long cached bounce into the login form instead of the file, with no way for us to invalidate it except renaming the path. The fix is a deletion, not another rule.
/static/ with the trailing slash answers 308 instead, because Next normalises the trailing slash before anything else gets a look in.
How to tell our immutable header from the real one
Next sets the same intent on real assets, and the two are distinguishable by whitespace. Ours is written as a string in the config with spaces after the commas. Next's is generated:
# ours, on a path with nothing behind it
cache-control: public, max-age=31536000, immutable
# Next's, on a chunk that really is content hashed
curl -sI https://pub-trivia.app/_next/static/chunks/1owsg2rvp5-1f.js
cache-control: public,max-age=31536000,immutable
Two spaces are the difference between a header we wrote and a header the framework wrote. That chunk is 10,902 bytes and is the one behind our round planner, if you want a live one to try.
The HSTS preload token that was already true
We send Strict-Transport-Security: max-age=63072000; includeSubDomains; preload. Two years, subdomains included, and the token that asks to be added to the browser preload list.
Ask the preload list about us:
curl -s "https://hstspreload.org/api/v2/status?domain=pub-trivia.app"
{ "name": "pub-trivia.app", "status": "preloaded", "bulk": false, "preloadedDomain": "app" }
preloadedDomain: "app" is the answer to a question we did not ask. We are preloaded, but not because of our header or any submission of ours. The entire .app top level domain is in the HSTS preload list, so every .app name is HTTPS only in a modern browser from the moment it is registered. Our preload token has nothing left to achieve on this domain. It costs nothing, and we are keeping it, but it is worth knowing that the protection came with the TLD rather than with the config.
The permission we deny and the geography we use anyway
Permissions-Policy: camera=(), microphone=(), geolocation=() denies all three to our own origin. Searching the app for navigator.geolocation, getCurrentPosition, getUserMedia and navigator.mediaDevices returns zero hits, so the policy is denying things nobody asked for, which is the right default for a quiz app.
It also looks, at a glance, like a claim that we do not care where you are. We do. Open the pricing page through a VPN exit node in Germany and the plans are priced in euros, in Sweden in kronor, in the UK in pounds. That has nothing to do with the browser geolocation API. It comes from a request header the edge attaches, read in one function:
return currencyForCountry(headerList.get('x-vercel-ip-country'))
A Permissions-Policy governs browser APIs. It has no opinion about request headers, and the practical lesson is that geolocation=() in your headers does not mean your site does not know which country the request came from. Ours does, in 58 of them.
The config flag that shows up in a response
One more worth pointing at, because it is a rare case of a build time option being visible from outside. next.config.mjs sets:
staleTimes: { dynamic: 30, static: 180 }
And a prerendered page answers with:
curl -sI https://pub-trivia.app/legal/cookies | grep -i stale
x-nextjs-stale-time: 180
That is the client router cache window, in seconds, echoed into the response so the router on the next navigation knows how long it may reuse what it has.
What I changed
Nothing yet. The dead rule has been live for months and is doing no damage while public/static/ does not exist. It is now in the notes as a deletion rather than something to grow into, which is the opposite of what the comment above it implies.
Everything above is checkable from a terminal in about thirty seconds. Start with the one that surprised me:
curl -sI https://pub-trivia.app/static/anything.css
A one-year immutable cache on a redirect to a login page is a funny thing to find in your own config, and I only found it because I went looking for what the rules match rather than what they were written for. If you have a /static/:path* rule in a Next project, check whether you still have the folder.
The rest of the site, if you want to see what the headers are wrapped around: pub-trivia.app, free tier, no card.
Top comments (0)