Why Your Dev Site Breaks in Safari but Not Chrome on Localhost
HSTS and upgrade-insecure-requests get honored on loopback by Safari alone; gate them to production and clear the pinned cache.
You are building a site, it looks perfect in Chrome, and then you open it in Safari on your local machine and it is a wreck. No styles applied, form controls rendered as raw browser defaults, the whole thing looking like a webpage from 1999. The HTML is all there, the structure is intact, but it is completely unstyled, and only in Safari, and only on localhost. Chrome on the same machine, same URL, is flawless.
Your localhost site renders unstyled only in Safari because Safari enforces the CSP upgrade-insecure-requests directive and a cached HSTS pin on loopback, upgrading every asset request to HTTPS that your no-TLS dev server cannot answer. Gate both upgrade-insecure-requests and the Strict-Transport-Security header to production only, then clear Safari's cached HSTS entry so it stops upgrading.
This one has burned a lot of developers, partly because the cause is invisible and partly because it persists even after you fix the code, which makes it feel like you are going crazy. The reason is that Safari is the strict one. It honors two security behaviors on localhost that every other browser treats as no-ops there, and it caches one of them so aggressively that the symptom outlives the fix. It is one of a small family of Safari-only bugs worth knowing as a set, alongside Next.js 15 pages that render unstyled only in Safari, the Safari-only 520 error that large auth cookies quietly cause, and why your site returns 520 in Safari but works in Chrome at the CDN edge. Once you know the two behaviors and the one cache, it takes minutes to resolve. Here is the whole thing.
Why only Safari, only on localhost
Two separate mechanisms conspire here, and Safari is the only major browser that applies either of them to loopback addresses like localhost and 127.0.0.1.
The first is the Content Security Policy directive upgrade-insecure-requests. When this directive is present, the browser rewrites every http:// request on the page to https:// before sending it. Safari does this even for http://localhost. Your dev server is plain HTTP with no TLS, so when Safari upgrades the request for your stylesheet to https://localhost/styles.css, there is nothing listening on HTTPS, the request fails, and the CSS never loads. The HTML rendered, because the document itself already loaded, but every asset it tried to pull got upgraded to a URL that does not work. Hence the unstyled page: the structure is there, the styles are 404ing.
The second is HSTS, the Strict-Transport-Security header. Once Safari sees this header on a host, it pins that host to HTTPS for the header's max-age, which is commonly set to two years. From then on, Safari refuses to make plain HTTP requests to that host and silently upgrades them, all on its own, no CSP needed. And here is the part that makes the bug feel haunted: Safari caches this pin on disk, in a file at ~/Library/Cookies/HSTS.plist, and it persists across dev sessions and even after you change your code. So you can fix the header in your app, restart everything, and Safari will keep upgrading requests to HTTPS because it is reading from a cached pin it set earlier.
Chrome and Firefox treat both of these as no-ops on loopback, per the secure-context rules that consider localhost a special case. That is why the bug is Safari-only and localhost-only. Safari is enforcing security headers that, in production, you absolutely want, but in local development against a no-TLS server, they break everything.
The fix has two halves, and you need both
This is the trap: fixing the code is necessary but not sufficient, because the HSTS pin is cached separately from your code. You have to fix the headers and clear Safari's cache, or the symptom survives.
Half one: gate the headers to production
The real correction in your application is that both of these headers belong in production and nowhere else. In production your site is served over HTTPS, so upgrade-insecure-requests and HSTS do exactly the right thing. In development against a plain HTTP localhost, they do exactly the wrong thing. So gate them on the environment, the same NODE_ENV discipline that should also wrap CSRF protection that survives OAuth callbacks and the other environment-sensitive security middleware.
For the HSTS header, only set it when running in production:
const isProd = process.env.NODE_ENV === 'production';
if (isProd) {
headers.push({
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
});
}
And for the CSP, only add the upgrade directive in production:
if (isProd) directives.push('upgrade-insecure-requests');
Now in development, neither header is sent, Safari has no reason to upgrade your localhost requests, and your assets load over plain HTTP as intended. In production, both headers are present and doing their job. This is the correct setup regardless of Safari: HSTS and upgrade-insecure-requests are production behaviors that should never be active against a no-TLS dev server in the first place. HSTS is just one entry in the broader set of security headers every Next.js app should ship, and the same environment-aware gating keeps the rest from misfiring in development. The silent rule a lot of security guides leave out is "in production only," not "always on."
Half two: clear Safari's pinned cache
Fixing the code stops Safari from setting a new pin, but it does nothing about the pin Safari already has. If Safari saw your HSTS header even once during development, it cached the pin and will keep upgrading your localhost requests from that cache until the two-year max-age runs out, which is not a thing you want to wait for.
So you have to clear it. The targeted way is through Safari's settings: go to Privacy, then Manage Website Data, search for localhost, and remove it. The more aggressive way, which wipes all HSTS pins, is to delete the cache file directly:
rm ~/Library/Cookies/HSTS.plist && killall Safari
After clearing the cache and restarting Safari, the browser has no pinned upgrade for localhost, your gated headers mean it will not set a new one, and the page finally loads styled. This second step is the one developers do not figure out on their own, because nothing about an unstyled page suggests "a cached security pin in a plist file is overriding your fixed code." Tell anyone you hand this to that clearing the HSTS cache is part of the fix, not optional.
How to spot it fast next time
The fastest way to confirm this diagnosis, before you change anything, is to check what headers your dev server is actually sending. Curl the localhost URL and look for the two suspects:
curl -sI http://localhost:3000/ | grep -iE 'strict-transport-security|content-security-policy'
If you see a Strict-Transport-Security header or a CSP containing upgrade-insecure-requests coming back from your development server, that is the bug, confirmed in one command. A site that loads in Chrome but renders unstyled in Safari on localhost, with either of those headers present in dev, is this exact issue every time.
This kind of browser-specific, environment-specific gotcha is the sort of thing that eats an afternoon if you have not seen it and takes two minutes if you have. The other half of getting development right is that one command should bring the whole stack up cleanly, the goal of making pnpm dev bring up your whole stack in one command. It is also a small example of a larger discipline: security headers are powerful and you want them in production, but they have to be applied with awareness of the environment they run in, which is part of getting both the website build and its security posture right rather than copy-pasting a headers block that breaks local development. Set the headers correctly, gate them to production, and clear the one cache Safari clings to, and the haunted unstyled page goes away for good.
The short version
A site that works in Chrome but renders unstyled in Safari on localhost is almost always Safari honoring upgrade-insecure-requests and HSTS on loopback, which no other browser does, and then caching the HSTS pin so the breakage outlives your fix. The resolution is to gate both headers to production so they never reach your dev server, and to clear Safari's HSTS cache so the pin it already set stops upgrading your requests. Two halves, both required. Do them and Safari stops being the strict browser that breaks your local build, and goes back to rendering your page exactly like Chrome does.






