Diagnosing a Flash of Unstyled Content in TanStack Start Production
The report
I was polishing the landing page for Biormin — the ticketing SaaS I’ve been building. The page looked great on my machine. Then a friend opened it in production and messaged me:
There’s a flicker before things render properly. It’s not great for users.
I opened the deployed site. He wasn’t exaggerating. For about a second, the page rendered as raw, unstyled HTML — like it was 1998 — before snapping into the branded design.
This post walks through the four wrong-but-plausible diagnoses I went through, and the actual root cause. If you’re running TanStack Start with SSR and Tailwind, this bug can bite you too.
Diagnosis #1: the scroll-reveal animation
My first instinct was the scroll-triggered reveal I had on the landing sections. Something like this:
export function Reveal({ children }) {
const [visible, setVisible] = useState(false)
const ref = useRef(null)
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => entry.isIntersecting && setVisible(true),
{ threshold: 0.15 },
)
observer.observe(ref.current)
return () => observer.disconnect()
}, [])
return (
<div
ref={ref}
className={cn(
'transition-all duration-700',
visible ? 'translate-y-0 opacity-100' : 'translate-y-6 opacity-0',
)}
>
{children}
</div>
)
}
Do you see the problem?
visible starts as false. That means on the initial paint — both SSR and hydration — the wrapped elements render with opacity-0. The IntersectionObserver only flips visible=true after React’s effects run, which is a frame or two later. For content already in the viewport (hero, first feature grid), this means:
- SSR ships HTML with
opacity-0 translate-y-6 - Browser paints invisible content
- React hydrates, effect runs, observer fires immediately
- Content pops in
Classic pop-in flash. Not what the user was reporting though — the user described unstyled HTML, not invisible-then-visible content. But the fix was worthwhile:
const [visible, setVisible] = useState(true) // default visible
const [animate, setAnimate] = useState(false)
useLayoutEffect(() => {
if (reduced) return
const node = ref.current
const rect = node.getBoundingClientRect()
const inViewport = rect.top < window.innerHeight && rect.bottom > 0
if (inViewport) return // already visible → skip animation
setAnimate(true) // below fold → hide + reveal on scroll
setVisible(false)
}, [reduced])
Now above-the-fold content paints instantly. Below-the-fold content still animates in on scroll. SSR HTML always renders fully-opaque. No pop-in.
But the “unstyled HTML” flash was still there.
Diagnosis #2: a giant indigo logo
Poking further, the friend added: “There’s a giant indigo logo that appears before things render.”
That was a new clue. I looked at the header’s logo component:
export function BrandMark({ className }) {
return (
<svg
viewBox="0 0 32 32"
className={className}
fill="none"
>
<rect width="32" height="32" rx="8" fill="currentColor" />
<path d="M10.5 8.5h6.25c2.9 0..." fill="var(--primary-foreground)" />
</svg>
)
}
Usage: <BrandMark className="size-8 text-primary" />.
The dimensions rely entirely on Tailwind’s size-8 class. The color relies on text-primary (via currentColor). Neither is set as an SVG attribute.
Before the CSS bundle is applied, this SVG has:
- No intrinsic width/height → browsers default to 300×150 or fill the parent
- No
colorpresentation attribute →currentColorinherits from the parent<Link>, which is the browser’s default link color (a bluish-indigo)
So during the FOUC window: a 300×150-ish, bluish-indigo logo. The giant indigo logo the friend saw.
Fix — add presentation-attribute fallbacks that Tailwind still overrides once the CSS applies:
<svg
viewBox="0 0 32 32"
width="32"
height="32"
color="#242424"
className={className}
fill="none"
>
CSS wins over presentation attributes at steady state, so size-* and text-primary still work. But before the CSS applies, the SVG is 32×32 and near-black instead of gigantic and indigo.
The friend confirmed the giant logo was gone. But the flash of unstyled HTML remained.
Diagnosis #3: FOUT, not FOUC
Time to actually measure. I asked for DevTools numbers. The CSS came back with these timings:
- CSS bundle #1: ~412ms, tiny (
0.3 KB) - CSS bundle #2: ~206ms, tiny (
0.5 KB) - CSS bundle #3: ~206ms, ~
20 KB
Twenty kilobytes of CSS delivered in under half a second. With data-precedence="default" on the <link> (React 19’s blocking stylesheet API) the browser should refuse to paint until it’s in. So how can we have a full second of unstyled render?
I looked at styles.css:
@import 'tailwindcss';
@import 'tw-animate-css';
@import 'shadcn/tailwind.css';
@import '@fontsource-variable/inter';
@import '@fontsource/cal-sans/400.css';
@import '@fontsource-variable/geist-mono';
There it is. The fonts are @imported inside the CSS. That means the browser can only start fetching them after it’s parsed the CSS. Timeline:
0ms— HTML arrives~412ms— CSS arrives, browser parses it~412ms— browser discovers@font-facerules and starts fetching Inter + Cal Sans + Geist Mono~800–1000ms— fonts arrive, all text reflows from the system fallback into the branded font
That extra ~500ms of “unstyled” perception isn’t unstyled HTML at all — it’s Flash of Unstyled Text (FOUT). The layout is correct, colors are correct, but every heading reflows from a system font into Cal Sans. On a landing page with big heroic type, the visual jump reads exactly like “styles just kicked in”.
Fix — preload the visible fonts in the root <head> so they fetch in parallel with the CSS:
import interFontUrl from '@fontsource-variable/inter/files/inter-latin-wght-normal.woff2?url'
import calSansFontUrl from '@fontsource/cal-sans/files/cal-sans-latin-400-normal.woff2?url'
createRootRoute({
head: () => ({
links: [
{ rel: 'stylesheet', href: appCss },
{
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: interFontUrl,
crossOrigin: 'anonymous',
},
{
rel: 'preload',
as: 'font',
type: 'font/woff2',
href: calSansFontUrl,
crossOrigin: 'anonymous',
},
// …
],
}),
})
Two subtleties worth calling out:
- The
?urlimport lets Vite bundle the woff2 with a hash and gives us the resolved URL. If you hardcode the path, cache-busting stops working. crossOrigin: 'anonymous'is mandatory. Font requests are always CORS. If the preload’s CORS mode doesn’t match the@font-facerequest, the browser fetches the file twice — which somehow feels even worse than not preloading at all.
Deployed. The friend tested again. Still unstyled for a beat.
Diagnosis #4: view-source vs. Inspect Element
At this point I was suspicious of my own assumptions. I asked for the raw HTML — not the DevTools Elements panel, but view-source: on the deployed URL.
In the rendered DOM (Inspect Element) there were two stylesheets:
<link rel="stylesheet" href="/assets/styles-BPRkP6zC.css" data-precedence="default">
<link rel="stylesheet" href="/assets/styles-bkAC7xsd.css" data-precedence="default">
In view-source (the actual bytes the server sent) there was only one:
<link rel="stylesheet" href="/assets/styles-BPRkP6zC.css" data-precedence="default"/>
The second <link> was inserted by React after initial paint. That was the entire bug. The server was streaming HTML with body content that referenced classes defined in styles-bkAC7xsd.css, but that stylesheet was nowhere in the head. The browser dutifully rendered the body with browser-default styles — the actually-unstyled HTML the friend was reporting — until React finished hydrating and appended the second <link>.
Cross-referencing rendered DOM with view-source is a habit I’d let atrophy. In modern SSR you have to check both. The Elements panel reflects the DOM now; view-source reflects what the server actually sent. When they disagree, that gap is where FOUC lives.
The root cause
Digging into TanStack Router issue #3023 — 74 comments across two years — I found the exact failure mode. Quoting community fix from that thread:
globals.cssis processed twice during build (once via?urlimport for SSR manifest, once as a transitive dep of@blocknote/shadcn) producing two different hashes. A stable filename ensures the SSR manifest URL always matches the deployed file.
Same shape as mine. My styles.css was imported via ?url in __root.tsx for the SSR shell’s <link>. But Vite’s build pass produced another copy under a different hash, because styles.css is also pulled in transitively — in my case through shadcn/tailwind.css and @fontsource imports.
Vite’s default assetFileNames uses content hashes. Two build passes producing two slightly-different derived files means two different hashes. The URL baked into the SSR manifest points at one; the file actually deployed uses the other. React 19’s stylesheet hoisting later inserts the “correct” one it discovers in the module graph — but only during hydration, after the initial paint.
Three fixes, applied together in vite.config.ts:
tanstackStart({
// Inline CSS into the SSR HTML instead of a separate blocking request.
// Sidesteps the hash mismatch entirely on newer Start versions.
server: { build: { inlineCss: true } },
}),
nitro({
// Hashed assets are immutable. Without this, browsers refetch CSS on
// client-side navigation and show FOUC while the request is in flight.
routeRules: {
'/assets/styles.css': {
headers: { 'cache-control': 'public, max-age=0, must-revalidate' },
},
'/assets/**': {
headers: { 'cache-control': 'public, max-age=31536000, immutable' },
},
},
}),
build: {
rollupOptions: {
output: {
// styles.css can be processed twice during build. A stable filename
// (no content hash) guarantees the SSR manifest URL always matches
// the file actually deployed.
assetFileNames: (assetInfo) => {
if (assetInfo.name === 'styles.css') {
return 'assets/styles.css'
}
return 'assets/[name]-[hash][extname]'
},
},
},
},
The stable filename is the “belt”. inlineCss: true is the “suspenders” — on TanStack Start versions where it takes effect, the CSS is inlined into the shell HTML directly and no external file is needed. The Nitro cache rules kill a separate FOUC path during client-side navigation.
Takeaways
Four things I want to remember from this:
- FOUC and FOUT look identical to users. Test with a slow-3G throttle in DevTools and check what specifically jumps: font, layout, colors, or everything. Different jumps have different root causes.
view-sourceis not the Elements panel. In SSR apps, the Elements panel shows the hydrated DOM. When diagnosing initial-paint bugs, view-source is what you want.- SVG-as-icon needs presentation-attribute fallbacks.
viewBoxalone won’t stop the browser from rendering it at 300×150 during the FOUC window. Always shipwidth,height, and — if you usecurrentColor— acolorfallback. - React 19’s
<link precedence>doesn’t guarantee shell inclusion. During streaming SSR, stylesheets discovered mid-render can end up inserted after the initial body chunk. If your bundler has a hash-mismatch bug (like the one that inspired this post), that’s where it will surface.
If you’re on TanStack Start and see a FOUC in production that doesn’t reproduce locally, start by cross-referencing view-source against Inspect Element. That’s the check that would have saved me two hours.