{"id":72,"date":"2026-08-06T17:50:37","date_gmt":"2026-08-06T17:50:37","guid":{"rendered":"https:\/\/www.guicoder.com\/blog\/?p=72"},"modified":"2026-08-06T19:54:55","modified_gmt":"2026-08-06T19:54:55","slug":"fixing-client-side-react-seo-with-puppeteer-and-react-helmet-async","status":"publish","type":"post","link":"https:\/\/www.guicoder.com\/blog\/web-performance-seo\/fixing-client-side-react-seo-with-puppeteer-and-react-helmet-async\/","title":{"rendered":"Fixing Client-Side React SEO with Puppeteer and react-helmet-async"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Single-page React apps have a well-known SEO problem. A crawler that doesn&#8217;t execute JavaScript hits your site and sees this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;div id=\"root\"&gt;&lt;\/div&gt;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Nothing else. No title, no description, no content. Everything a search engine or a link-unfurling bot cares about only exists after React mounts, runs its components, and renders into that div. Some crawlers do execute JS now, but not reliably, not consistently, and not for every route on your site with the same timing guarantees. If SEO matters to you, you can&#8217;t leave that to chance.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I ran into this directly while working on my portfolio site, guicoder.com. Here&#8217;s the two-part fix: react-helmet-async to manage per-route metadata, and a custom Puppeteer prerendering script to bake that metadata into static HTML at build time.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Part one: per-route metadata with react-helmet-async<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">By default, a React SPA has one <code>&lt;title&gt;<\/code> and one set of meta tags defined once in <code>public\/index.html<\/code>. Every route shares them. That&#8217;s a problem if you want each page to describe itself accurately: your resume page and your samples page shouldn&#8217;t have identical titles and descriptions.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">react-helmet-async solves this by letting each route declare its own head content as part of its component tree. A page component might render something like:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>&lt;Seo\n  title=\"Samples | Guicoder\"\n  description=\"A collection of accessible React components and applications.\"\n  canonical=\"https:\/\/guicoder.com\/samples\"\n\/&gt;\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Internally, that component uses <code>react-helmet-async<\/code>&#8216;s <code>&lt;Helmet&gt;<\/code> to inject the title, description, canonical link, and Open Graph tags into <code>&lt;head&gt;<\/code> at runtime. Navigate to a different route, and the tags update to match.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">This is a real improvement, but it only exists after JavaScript runs. It&#8217;s a runtime fix. If a crawler doesn&#8217;t execute your JS, or gives up before helmet&#8217;s effect fires, none of it matters.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Part two: capturing the rendered output with Puppeteer<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is where prerendering comes in. The idea: instead of hoping a crawler will run your JS correctly, run it yourself, once, at build time, and save the result as static HTML.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The rough shape of the process:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Build the app normally.<\/li>\n\n\n\n<li>Spin up a local static server serving the build output.<\/li>\n\n\n\n<li>Launch a headless browser (Puppeteer) and visit each route.<\/li>\n\n\n\n<li>Wait for React to mount and helmet to inject its tags.<\/li>\n\n\n\n<li>Grab the fully-rendered HTML from the page.<\/li>\n\n\n\n<li>Write that HTML to disk at the matching route path.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The result is a set of static files like <code>build\/samples\/index.html<\/code>, <code>build\/resume\/index.html<\/code>, each containing the correct title, description, canonical URL, and OG tags for that specific route, already present in the markup a crawler receives on first load. Once the page&#8217;s own JS kicks in, React hydrates over that markup and the app behaves like a normal SPA from there.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The part that&#8217;s easy to get wrong: timing<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The tricky detail is step 4. React mounting and helmet committing its tags don&#8217;t happen instantly, and they don&#8217;t happen in lockstep with typical &#8220;page ready&#8221; signals like Puppeteer&#8217;s <code>networkidle0<\/code>. If you snapshot too early, you capture the DOM before helmet has written anything route-specific, and every one of your prerendered pages ends up with the same default metadata baked in. That defeats the entire point.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix is to wait for a specific, verifiable signal rather than a generic timeout. Checking for <em>any<\/em> non-empty meta description tag isn&#8217;t reliable if a static default value already exists in your HTML template; that check can pass before the route-specific value ever loads. A more precise approach is to wait for the canonical link tag to match the exact URL expected for that route:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>await page.waitForFunction(\n    (expectedPath) =&gt; {\n      const canonical = document.querySelector('link&#091;rel=\"canonical\"]');\n      return !!canonical &amp;&amp; canonical.href.endsWith(expectedPath);\n    },\n    {timeout: 10000},\n    route,\n);\n<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">This only proceeds once the canonical tag actually reflects the current route, which means helmet has committed its changes and the snapshot will be accurate. Only after that check passes does the script call <code>page.content()<\/code> and write the file.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why not just use an existing tool<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Tools like react-snap package this whole workflow, using Puppeteer under the hood with generic heuristics for detecting when a page is ready. They work fine for simple cases, but the heuristics aren&#8217;t aware of your app&#8217;s specific rendering pipeline or of exactly when helmet commits its changes. That mismatch is precisely the class of bug described above, and it&#8217;s easier to introduce than to catch. Writing the prerender script by hand costs more upfront, but it means the &#8220;is this page actually ready&#8221; check is built around something you control and can verify directly, rather than a guess made by a generic tool.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The result<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Two pieces working together solve the problem neither solves alone:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>react-helmet-async<\/strong> gives each route control over its own metadata at runtime.<\/li>\n\n\n\n<li><strong>Puppeteer prerendering<\/strong> captures that metadata into static HTML at build time, so it&#8217;s present before any JavaScript runs.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A crawler hitting any route on the site now gets a complete, accurate <code>&lt;head&gt;<\/code> on the very first response. No JS execution required, no waiting, no guessing.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Single-page React apps have a well-known SEO problem. A crawler that doesn&#8217;t execute JavaScript hits your site and sees this: Nothing else. No title, no description, no content. Everything a search engine or a link-unfurling bot cares about only exists after React mounts, runs its components, and renders into that div. Some crawlers do execute [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[5],"tags":[],"class_list":["post-72","post","type-post","status-publish","format-standard","hentry","category-web-performance-seo"],"_links":{"self":[{"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/posts\/72","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/comments?post=72"}],"version-history":[{"count":1,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/posts\/72\/revisions"}],"predecessor-version":[{"id":73,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/posts\/72\/revisions\/73"}],"wp:attachment":[{"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/media?parent=72"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/categories?post=72"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.guicoder.com\/blog\/wp-json\/wp\/v2\/tags?post=72"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}