{"id":"l6r1q1eir7asl3o","title":"How to Get PageSpeed 100 on a React Site (Without Next.js or a Framework Rewrite)","slug":"pagespeed-100-react-without-nextjs","summary":"PageSpeed 100 on a React marketing site without Next.js: head order, prerender shells, LCP images, and edge cache — with the stack behind briancrabtree.me.","imageUrl":"https://briancrabtree.me/images/journal-pagespeed-100-react-without-nextjs.webp","category":"Performance","date":"2026-06-12T18:00:00.000Z","featured":false,"likes":12,"author":"Brian Crabtree","content":"<h2>PageSpeed 100 does not require a framework rewrite</h2>\n\n<p>Teams hear “React is slow” and reach for Next.js, Remix, or a full rebuild. Often the site is a marketing SPA on Vite with a modest bundle — and the real failures are head order, missing prerender prose, hero image weight, and CDN cache headers. None of those require abandoning your current stack.</p>\n\n<p>I run <a href=\"https://briancrabtree.me/\">briancrabtree.me</a> as React 19 + Vite with vanilla CSS, static prerender shells, and postbuild head rewriting. Published lab scores from PageSpeed Insights (not datacenter Docker runs): <strong>100/100/100/100</strong> mobile and desktop — performance, accessibility, best practices, SEO. The tactics below are what actually moved the needle.</p>\n\n<p>Lab 100 is not a guarantee of green field data. Read <a href=\"/journal/lab-vs-field-data-pagespeed/\">Lab vs Field Data in PageSpeed Insights: When a 100 Score Still Fails Core Web Vitals</a> and <a href=\"/journal/why-pagespeed-scores-change-every-run/\">Why PageSpeed Scores Change Every Run</a> before you screenshot a score for a client deck. This post is about reproducible lab wins on production URLs — the same bar Google’s PSI tool uses.</p>\n\n<h2>Proof on a live React URL</h2>\n\n<p>Verify on the deployed origin, not <code>localhost</code>.</p>\n\n<pre><code># Response headers — cache and content type\ncurl -sI \"https://briancrabtree.me/\" | rg -i 'HTTP/|content-type|cache-control'\n\n# Hashed CSS should be long-cache immutable\ncurl -sI \"https://briancrabtree.me/assets/index.css\" 2>/dev/null | rg -i cache-control\n\n# PSI lab (replace with your URL)\n# https://pagespeed.web.dev/analysis?url=https://briancrabtree.me/</code></pre>\n\n<p>I store the canonical PSI run in <code>public/lighthouse-scores.json</code> with capture timestamp and analysis links. If your score moves between runs, compare waterfalls — not vibes. For the full ordered audit pass, see <a href=\"/journal/website-performance-audit-2026-react/\">Website Performance Audit Checklist (2026)</a>.</p>\n\n<h2>1 — CSS before module JS (non-negotiable)</h2>\n\n<p>Default Vite output places <code>&lt;script type=\"module\"&gt;</code> before the main stylesheet. Users see unstyled HTML; LCP waits on hydration. Fix the build, not the components.</p>\n\n<pre><code>&lt;link rel=\"preload\" href=\"/assets/index-HASH.css\" as=\"style\"&gt;\n&lt;link rel=\"stylesheet\" href=\"/assets/index-HASH.css\"&gt;\n&lt;script type=\"module\" src=\"/assets/index-HASH.js\"&gt;&lt;/script&gt;</code></pre>\n\n<p>Postbuild rewrites every production shell so this order never drifts. Full write-up: <a href=\"/journal/react-css-before-module-js-pagespeed/\">Why Your React Site Loads Unstyled (CSS Before Module JS)</a>. Never defer main CSS while module JS runs first on marketing pages — that invariant is in our PSI gate docs.</p>\n\n<h2>2 — Prerender shells: paint and crawl before hydrate</h2>\n\n<p>A blank <code>&lt;div id=\"root\"&gt;</code> scores poorly and indexes poorly. I emit per-route HTML shells with inline critical hero styles, canonical tags, and article prose for journal URLs. The React app still hydrates; the first response is not empty.</p>\n\n<p>Pattern: <a href=\"/journal/static-prerender-shells-spa/\">Static Prerender Shells for SPAs</a>. Pair with crawl checks from <a href=\"/journal/technical-seo-audit-react-spa/\">Technical SEO Audit for React SPAs</a> — performance and indexing share the same first byte.</p>\n\n<pre><code>curl -sL \"https://briancrabtree.me/journal/pagespeed-100-react-without-nextjs/\" \\\n  | rg -i 'canonical|prerender-post-prose|&lt;h2'</code></pre>\n\n<h2>3 — LCP image discipline</h2>\n\n<p>Hero images dominate LCP on marketing sites. Self-host WebP (or AVIF where supported), explicit dimensions, <code>fetchpriority=\"high\"</code> on the LCP candidate, and <code>srcset</code> so mobile does not download desktop pixels.</p>\n\n<p>Do not preload five font files while the hero image loses the bandwidth race. Details: <a href=\"/journal/image-srcset-lcp-sizing/\">Image srcset and LCP Sizing</a>, <a href=\"/journal/font-loading-without-layout-shift/\">Font Loading Without Layout Shift</a>, <a href=\"/journal/webp-avif-modern-image-delivery/\">WebP and AVIF Delivery</a>.</p>\n\n<h2>4 — Lazy everything below the fold</h2>\n\n<p>Route-level code splitting is table stakes. Journal grids, project modals, and admin-only chunks should not ship on the homepage critical path. Measure with Lighthouse’s “unused JavaScript” audit and your own bundle report — then cut or defer.</p>\n\n<p>Third-party scripts are the usual budget breaker after your bundle is lean: <a href=\"/journal/tag-manager-performance-tax/\">Tag Manager Performance Tax</a>, <a href=\"/journal/third-party-scripts-performance-cost/\">Third-Party Scripts Cost</a>. A “100” lab score and a 400kb GTM container are incompatible.</p>\n\n<h2>5 — Edge cache without self-sabotage</h2>\n\n<p>Hashed assets should return <code>Cache-Control: public, max-age=31536000, immutable</code>. If your origin sets <code>Set-Cookie</code> on static JS/CSS, Cloudflare may BYPASS cache and users re-download the same chunk every navigation.</p>\n\n<p>Fix: <a href=\"/journal/cloudflare-set-cookie-bypasses-edge-cache/\">Cloudflare Set-Cookie Bypasses Edge Cache</a>. After deploy, purge only what changed: <a href=\"/journal/cloudflare-cache-stale-assets-after-deploy/\">Stale Assets After Deploy</a>.</p>\n\n<h2>What I did not need</h2>\n\n<ul>\n  <li>Next.js App Router or RSC for a content marketing site</li>\n  <li>Tailwind or CSS-in-JS runtime on the critical path</li>\n  <li>A service worker caching strategy on day one</li>\n  <li>Manual <code>useMemo</code> / <code>memo</code> everywhere — React Compiler handles most of that</li>\n</ul>\n\n<p>Framework choice matters for product complexity. For a fast marketing site, build pipeline and asset discipline beat framework religion. See <a href=\"/journal/react-spa-overhead-real-cost/\">React SPA Overhead — Real Cost</a> for when the SPA tax is worth paying.</p>\n\n<h2>Copy-paste checklist</h2>\n\n<pre><code>- [ ] PSI mobile + desktop on production URL\n- [ ] Main CSS preload + blocking link BEFORE module script\n- [ ] Prerender shell with inline LCP-critical styles\n- [ ] LCP image: dimensions, fetchpriority, modern format, srcset\n- [ ] Fonts: preload only above-fold weights; font-display swap\n- [ ] Below-fold routes lazy-loaded; no admin chunk on /\n- [ ] CDN HIT on hashed /assets/*; no Set-Cookie on static files\n- [ ] curl: canonical + h2 prose on key templates\n- [ ] Document PSI analysis URL before claiming scores</code></pre>\n\n<h2>When lab 100 still is not enough</h2>\n\n<p>INP, CLS field data, and low traffic volumes can leave CrUX empty or amber while lab is green. Next fixes: <a href=\"/journal/interaction-to-next-paint-inp-guide/\">INP Guide</a>, <a href=\"/journal/hydration-cls-react-fixes/\">Hydration CLS Fixes</a>, <a href=\"/journal/performance-checks-before-handoff/\">Performance Checks Before Handoff</a>.</p>\n\n<h2>Scope this on your site</h2>\n\n<p>If you want PageSpeed 100 on a React marketing site without a framework rewrite, send your production URL and build tool at <a href=\"/contact?ref=journal-pagespeed-100\">/contact</a>. I will tell you whether head order and prerender are enough — or whether the problem is asset weight, crawl HTML, or cache headers. See <a href=\"/services/\">services</a> for how I scope performance work.</p>","tags":["pagespeed-insights","core-web-vitals","lighthouse","react","vite"],"views":38}