{"id":"9klmgmvvmg181fh","title":"Why Your React Site Loads Unstyled (CSS Before Module JS for PageSpeed)","slug":"react-css-before-module-js-pagespeed","summary":"Vite puts module JS ahead of CSS by default; preload plus a blocking stylesheet before the module script, with a prerender shell, stops the unstyled flash and is how I hold 98+ mobile PageSpeed on React marketing sites.","imageUrl":"https://briancrabtree.me/images/journal-react-css-before-module-js-pagespeed.webp","category":"Performance","date":"2026-06-08T18:00:00.000Z","featured":false,"likes":14,"author":"Brian Crabtree","content":"<h2>Why your React site loads unstyled on first paint</h2>\n\n<p>If your Vite or Create React App build flashes raw HTML before styles land, the head order is probably wrong—not your component tree. Default bundler output often places <code>&lt;script type=\"module\"&gt;</code> before the main stylesheet. The browser downloads and executes JavaScript immediately. React hydrates. The CSS file is still in flight. Users see an unstyled flash, Lighthouse marks render-blocking resources, and Largest Contentful Paint slips because the hero paints without layout rules.</p>\n\n<p>This is not a mystery bug in React 19. It is predictable parser behavior. Module scripts are high priority. Stylesheets block rendering when linked correctly—but only if they appear <em>before</em> the script that mounts your app. Put JS first and you have styled hydration racing the network. Put CSS first and the first paint matches your design system.</p>\n\n<p>I enforce that order on <a href=\"https://briancrabtree.me/\">briancrabtree.me</a> in postbuild, not by hand-editing <code>dist/index.html</code> after every deploy. The same rule applies to subpath demos like <a href=\"https://briancrabtree.me/highline/\">/highline/</a> when the deploy script emits head tags. Speed is a build contract, not a one-time audit fix.</p>\n\n<h2>The invariant: CSS before module JS</h2>\n\n<p>My PSI gate for the homepage is explicit: preload the main CSS bundle, keep a blocking <code>rel=\"stylesheet\"</code> link, and place both <em>before</em> the module entry script. Never defer main CSS while module JavaScript runs first—that pattern guarantees unstyled React on the critical path.</p>\n\n<p><code>scripts/postbuild-html.mjs</code> rewrites Vite output after every production build. It strips the default stylesheet and module script tags, then reinserts them in the right sequence immediately after the inline prerender <code>&lt;style&gt;</code> block: <code>rel=\"preload\" as=\"style\"</code> for early discovery, blocking stylesheet link, then <code>type=\"module\"</code>. One pass, every route shell, no drift between deploys.</p>\n\n<p>That is the difference between “we should fix PageSpeed someday” and a reproducible 98+ mobile score on a React marketing site. The audits stop complaining about chaos you introduced in the head, and LCP stops waiting on hydration to discover dimensions that CSS already defined.</p>\n\n<pre><code>- [ ] blocking stylesheet link appears before type=module script in built index.html\n- [ ] preload as=style for main CSS bundle\n- [ ] inline prerender style covers LCP hero dimensions\n- [ ] font preloads do not outrank LCP image fetch\n- [ ] PSI mobile run saved on production URL before claiming a score</code></pre>\n\n<h2>Prerender shell: paint before the bundle arrives</h2>\n\n<p>Head order alone is not enough if the hero depends entirely on a hashed CSS file. I inline a route-specific critical style block in the prerender shell—hero dimensions, background, typography scale—so the first paint is intentional even while the full bundle downloads. Crawlers and lab tools see real content, not a blank root div waiting on JavaScript.</p>\n\n<p>That pattern is the same idea behind <a href=\"/journal/static-prerender-shells-spa/\">static prerender shells for SPAs</a>: HTML carries enough structure to score and index before the client app boots. Postbuild merges per-route meta, canonical tags, and the inline shell into <code>dist/</code> copies for <code>/about/</code>, <code>/journal/</code>, and every journal slug. The React app still hydrates; the shell just refuses to look broken during the gap.</p>\n\n<h2>Font preload vs LCP image</h2>\n\n<p>After CSS order is correct, the next silent killer is competing preloads. Dropping <code>rel=\"preload\"</code> on every woff2 file feels responsible until your hero image loses the bandwidth race. On image-led marketing pages, the LCP candidate should win fetch priority—not display fonts for below-fold copy.</p>\n\n<p>I preload only what the above-fold shell needs, and I audit when scores move between runs. Lab variance is normal; chasing a single integer without reading the waterfall is how teams add preloads that hurt more than they help. For the full picture on run-to-run jitter, see <a href=\"/journal/why-pagespeed-scores-change-every-run/\">why PageSpeed scores change every run</a>.</p>\n\n<h2>What PageSpeed is actually measuring</h2>\n\n<p>Lighthouse will still list render-blocking CSS—that is correct. Stylesheets <em>should</em> block if you want styled first paint. The failure mode is blocking in the wrong order relative to JavaScript, or blocking on CSS that arrives too late because JS already mutated the DOM. Fixing head order does not mean async-loading your entire design system and accepting FOUC. It means blocking on the right file at the right time.</p>\n\n<p>On a recent luxury real estate handoff I documented in <a href=\"/journal/mock-first-handoff-before-mls-integration/\">mock-first handoffs before MLS integration</a>, the demo hit 98 mobile PageSpeed with self-hosted WebP heroes and no third-party search scripts on the critical path. Head order and prerender shell were part of that budget—not a post-launch surprise. I save the PSI analysis URL before claiming any number in a proposal.</p>\n\n<h2>Verify on production, not localhost</h2>\n\n<p>Run PageSpeed Insights on the deployed URL with mobile and desktop profiles. Open the built <code>index.html</code> in <code>dist/</code> and confirm the stylesheet link precedes the module script. Scroll on a phone. If the hero flashes unstyled, the head rewrite did not run or a deploy path skipped postbuild.</p>\n\n<p>For a broader pass before handoff, start with <a href=\"/journal/website-performance-audit-2026-react/\">Website Performance Audit Checklist (2026)</a>, then <a href=\"/journal/performance-checks-before-handoff/\">performance checks before handoff</a> and <a href=\"/journal/website-performance-audit-checklist/\">website performance audit checklist</a>. CSS-before-JS is one line item—but it is the one that separates fast React sites from fast-looking demos that fail on real networks.</p>\n\n<h2>When you need this fixed on your build</h2>\n\n<p>If your React site loads unstyled on first paint, or mobile PageSpeed stalls despite a small bundle, send a brief at <a href=\"/contact?ref=journal-pagespeed\">/contact</a> with your production URL and build tool (Vite, CRA, Next static export). I will tell you whether head reorder and prerender shell are enough—or whether the problem is asset weight, third-party scripts, or something else entirely. See <a href=\"/services/\">services</a> for how I scope performance work.</p>","tags":["react","vite","pagespeed","core-web-vitals","performance"],"views":30}