<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hayley Henning</title>
    <description>The latest articles on DEV Community by Hayley Henning (@virdix).</description>
    <link>https://dev.to/virdix</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4042177%2Fc0524cc7-12d1-4e8d-aeac-af1f3682cf87.png</url>
      <title>DEV Community: Hayley Henning</title>
      <link>https://dev.to/virdix</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/virdix"/>
    <language>en</language>
    <item>
      <title>Prerendering 280 pages of a React SPA for SEO: what actually worked</title>
      <dc:creator>Hayley Henning</dc:creator>
      <pubDate>Wed, 22 Jul 2026 14:26:55 +0000</pubDate>
      <link>https://dev.to/virdix/prerendering-280-pages-of-a-react-spa-for-seo-what-actually-worked-1inf</link>
      <guid>https://dev.to/virdix/prerendering-280-pages-of-a-react-spa-for-seo-what-actually-worked-1inf</guid>
      <description>&lt;p&gt;We shipped a Vite + React single page app with about 280 routes: marketing pages, a blog, and a batch of programmatically generated county pages. All client rendered. The problem is not users, it is crawlers and share cards. A raw SPA serves the same index.html for every route, so anything that does not execute JavaScript sees an identical, empty shell no matter which URL it requested. This post walks through the actual prerender pipeline we run after every build, what it gets right, what it does not, and the two gotchas that cost us the most time. The site is virdix.co if you want to see the output in practice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we did not just switch to a meta framework
&lt;/h2&gt;

&lt;p&gt;The obvious answer is "use Next.js" or "use Remix." We did not, for a boring reason: the app was already a mature Vite SPA with its own routing (wouter), its own build pipeline, and no server. Rewriting the framework to get SSR would have meant rewriting the app. What we actually needed was much narrower: crawlers and social scrapers need to see the right title, meta description, Open Graph tags, and JSON-LD for each URL. Real users, who run JavaScript, do not need any of that baked in, they get it from a React effect the moment the page mounts.&lt;/p&gt;

&lt;p&gt;So the fix is a post-build script, not a framework migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;vite build&lt;/code&gt; produces &lt;code&gt;dist/&lt;/code&gt;, a Node script does the following:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Spins up a tiny local HTTP server that serves &lt;code&gt;dist/&lt;/code&gt; as static files, falling back to the built &lt;code&gt;index.html&lt;/code&gt; shell for any path that is not a real file (the same behavior the production host needs to provide for client side routing to work).&lt;/li&gt;
&lt;li&gt;Launches headless Chromium via Puppeteer.&lt;/li&gt;
&lt;li&gt;For every route in a list, opens a new page, navigates to it, and waits for two things: the app's root element to have children, and &lt;code&gt;document.title&lt;/code&gt; to be non empty. That second check matters more than it sounds like it should.&lt;/li&gt;
&lt;li&gt;Serializes &lt;code&gt;document.documentElement.outerHTML&lt;/code&gt; and writes it to &lt;code&gt;dist/&amp;lt;route&amp;gt;/index.html&lt;/code&gt; (or &lt;code&gt;dist/index.html&lt;/code&gt; for the homepage, &lt;code&gt;dist/404.html&lt;/code&gt; for the not found route).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The route list itself is generated, not hand maintained. Static marketing routes live in an array, but blog slugs and county page slugs are discovered by reading the actual data files in the repo (&lt;code&gt;readdirSync&lt;/code&gt; over the county data directory, a regex over blog post metadata) so the prerender list can never silently drift out of sync with what the app actually renders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "wait for title" instead of a fixed timeout
&lt;/h2&gt;

&lt;p&gt;Early versions of this script used a flat &lt;code&gt;waitForTimeout(2000)&lt;/code&gt; after navigation. It worked until it did not: on a slower CI runner, some pages had not finished mounting by the 2 second mark, so we wrote empty shells to disk for a handful of routes and did not notice until a Search Console coverage report flagged pages with no content.&lt;/p&gt;

&lt;p&gt;The fix was to wait on an actual signal instead of a guess:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;waitForFunction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#root&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;45000&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per-page SEO in this app is set by a shared component that runs in a &lt;code&gt;useEffect&lt;/code&gt; and sets &lt;code&gt;document.title&lt;/code&gt;, meta tags, and injects a JSON-LD script tag. Waiting for the title to be non-empty is a reliable proxy for "this route's data has loaded and the SEO effect has run," because that effect is one of the last things to fire on mount. This also means the JSON-LD is not a separate step, it gets serialized into the static HTML for free as part of capturing the full DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotcha nobody warns you about: animation libraries
&lt;/h2&gt;

&lt;p&gt;We use framer-motion for entrance animations (fade and slide in on scroll or on mount). The first time we shipped prerendered pages, Lighthouse and actual users on a cold load saw a flash of invisible content: the serialized HTML captured elements mid animation, with inline &lt;code&gt;opacity: 0&lt;/code&gt; and a &lt;code&gt;translate&lt;/code&gt; transform baked in, and it took a full client hydration for React to catch up and make them visible. That is a bad first paint for a page that is supposed to be pre-rendered specifically to look done immediately.&lt;/p&gt;

&lt;p&gt;The fix runs right before serialization, inside the same Puppeteer page context:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelectorAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[style*="opacity"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;opacity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;translate|scale&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This walks the DOM and forces any element caught in a partial animation state to its final, visible state before we serialize the HTML. On the client side, the animation library is told to skip the entrance animation specifically on prerendered boots, so React does not fight the static HTML by re-running the fade-in on top of already-visible content. Static output looks done. Client boot does not re-animate something that is already there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting picked up faster: IndexNow
&lt;/h2&gt;

&lt;p&gt;Prerendering solves what crawlers see once they show up. It does not make them show up sooner. For that we run a small script after every production deploy that pings the IndexNow API (used by Bing, Yandex, and a few other participating engines) with every URL currently in the sitemap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;urlList&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.indexnow.org/indexnow&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is a token published as a text file at the site root, which is how IndexNow verifies you actually control the domain before it will act on your submission. A 200 or 202 response means it accepted the batch, 202 specifically means the key has not been validated by every participating engine yet, which is normal on the first run. This does not touch Google, which does not participate in IndexNow, but for the engines that do, new and changed county pages get queued for a crawl right after deploy instead of waiting for the next scheduled pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this approach does not do
&lt;/h2&gt;

&lt;p&gt;Worth being honest about the limits. This is a build time snapshot, not real SSR: if data changes between deploys, the static HTML is stale until the next build and prerender run. It also does not help with anything that depends on request time context (auth state, geolocation, A/B tests), because there is no server making per-request decisions, just static files. For a marketing site and a blog with mostly stable content, that tradeoff is fine. For an app with per-user server rendered state, it would not be.&lt;/p&gt;

&lt;p&gt;The whole thing is a few hundred lines of Node and Puppeteer, no framework rewrite, no server to run in production. If you are stuck with a client rendered SPA and cannot justify migrating it to get SSR, this is the pragmatic middle ground.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>seo</category>
      <category>react</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
