back to blog

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:

  1. SSR ships HTML with opacity-0 translate-y-6
  2. Browser paints invisible content
  3. React hydrates, effect runs, observer fires immediately
  4. 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.

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 color presentation attribute → currentColor inherits 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-face rules 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 ?url import 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-face request, 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.css is processed twice during build (once via ?url import 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:

  1. 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.
  2. view-source is 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.
  3. SVG-as-icon needs presentation-attribute fallbacks. viewBox alone won’t stop the browser from rendering it at 300×150 during the FOUC window. Always ship width, height, and — if you use currentColor — a color fallback.
  4. 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.