{"id":"2hj1n6hpu0blpm2","title":"Static Prerender Shells: SPAs That Paint Before JavaScript","slug":"static-prerender-shells-spa","summary":"I ship HTML shells with real copy and LCP images before the bundle hydrates. Users and crawlers get substance, not a spinner.","imageUrl":"https://briancrabtree.me/images/journal-static-prerender-shells-spa.webp","category":"Performance","date":"2026-04-20T18:00:00.000Z","featured":false,"likes":20,"author":"Brian Crabtree","content":"<h2>The blank root problem</h2>\n\n<p>div id root with a spinner is a performance and SEO anti-pattern on public URLs. Crawlers may index the spinner era. Users stare at white while parse and compile run. I replace that with static HTML mirroring the hydrated hero.</p>\n\n<p>Prerender is not full SSR on Node for every request. It is build-time or edge-cached HTML aligned with client components.</p>\n\n<p>Firebase Hosting rewrites can serve shell HTML per route while API stays dynamic. Config is infrastructure as code in firebase.json. Lighthouse sometimes scores shell-only HTML highly then drops after hydration. Test both states.</p>\n\n<pre><code>&lt;!-- prerender shell: real title + article before bundle --&gt;\n&lt;main&gt;\n  &lt;article&gt;\n    &lt;h1&gt;Field note title&lt;/h1&gt;\n    &lt;p&gt;Summary paragraph crawlers can read.&lt;/p&gt;\n  &lt;/article&gt;\n&lt;/main&gt;</code></pre>\n\n<p><figure>\n  <img src=\"/images/journal-inline-static-prerender-shells-spa.webp\" alt=\"Diagram comparing SPA fallback HTML with prerender shell showing real title and article content\" width=\"1200\" height=\"675\" loading=\"lazy\" />\n  <figcaption>Crawlers read the first HTML response — prerender shells put real copy in that byte.</figcaption>\n</figure></p>\n\n<h2>What belongs in the shell</h2>\n\n<p>H1, lead paragraph, primary image with dimensions, nav links, and critical CSS inline or linked. Not the entire app state. Below-fold widgets can stay placeholders with reserved height.</p>\n\n<p>Journal entries on briancrabtree.me ship body HTML in content files; React enhances navigation and likes, it does not author the article twice.</p>\n\n<p>Edge functions that prerender on first request are middle ground between build-time and SSR. Cache TTL defines cost. Service worker precache of shell HTML must version on content hash, not only JS hash.</p>\n\n<h2>Keeping shell and client in sync</h2>\n\n<p>Build pipeline generates shell from same data source as client routes. MDX, content collections, or custom script reading Firestore export. Drift happens when marketing edits CMS but static job does not run; CI must fail if shell stale.</p>\n\n<p>Sitemap XML should list prerendered URLs only after shell job completes in CI, not on developer laptop manual run.</p>\n\n<p>Partial hydration experiments label interactive islands explicitly in HTML comments for future maintainers.</p>\n\n<h2>Hydration without hiding content</h2>\n\n<p>Do not display none the shell until React mounts. That delays LCP to hydration. I style shell and client to match, hydrate in place, remove only duplicate nodes if unavoidable with careful keys.</p>\n\n<p>Structured data in shell must match JSON client router will hydrate or rich results get inconsistent dates.</p>\n\n<p>Edge cache purge API hooks into CMS publish button save editor workflow time.</p>\n\n<h2>Tools in the toolbox</h2>\n\n<p>Vite prerender plugin, react-snap legacy patterns, Astro static pages with islands, Next static export where dynamic needs are modest. Pick based on deploy target: S3 plus CDN, Firebase Hosting, or nginx static root.</p>\n\n<p>I test curl -A Googlebot on shell HTML without executing JS. What you see is what many crawlers index.</p>\n\n<p>Legal cookie text in shell must match post-hydration consent state or regulators notice.</p>\n\n<h2>Measuring success</h2>\n\n<p>LCP element should be in first HTML chunk. View source must show copy. PSI mobile should not show blank Speed Index frames. If shell works, lab scores stabilize across runs more than spinner apps.</p>\n\n<p>Prerender does not remove need for accessible interactive components after hydration. Keyboard test post-hydration still required.</p>\n\n<p>A/B headline tests on shell need server assignment, not client-only randomness, for SEO consistency.</p>\n\n<h2>Offer</h2>\n\n<p>Send your view-source screenshot. If root is empty, I will sketch shell fields for your top three URLs and wire build step. Usually days, not months.</p>\n\n<p>Rollback plan: keep previous shell artifact in CDN versioned folder if bad copy ships without broken JS deploy.</p>\n\n<p>Internationalized shells need hreflang in head before JS locale detection runs. For a related angle I keep coming back to, see <a href=\"/journal/technical-seo-audit-react-spa/\">Technical SEO Audit for React SPAs (What Crawlers Actually See)</a>.</p>","tags":["prerender","spa","performance"],"views":53}