DEV Community

Pre-rendering a 160-post React SPA with react-snap: the gotcha that silently breaks your SEO

We ship a React SPA with about 160 blog posts and a handful of core routes, and rely on react-snap in the postbuild step to pre-render everything to static HTML for crawlers. It's a solid, low-maintenance setup - until it silently fails and you don't notice for days.

The setup react-snap drives a real headless Chrome instance (via Puppeteer) against your build output, crawling every route in an include list and writing the rendered HTML back to disk. Our config looks roughly like this:

"reactSnap": {
  "crawl": false,
  "source": "build",
  "puppeteerExecutablePath": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
  "concurrency": 1,
  "saveAs": "html",
  "include": ["/", "/blog", "..."]
}

Note puppeteerExecutablePath - we point it at the system-installed Google Chrome instead of the bundled Chromium, mainly to avoid re-downloading a browser binary in CI.

The gotcha

That one line is also the trap. If your actual desktop Google Chrome is open - especially with a lot of tabs - react-snap has to share system memory with it. On a ~168-route crawl, we started seeing the process get SIGKILLed (exit 137) partway through, consistently somewhere around route 45โ€“59.

Here's the dangerous part: the webpack build step before postbuild still exits 0. So your CI/CD or local npm run build looks completely successful. What actually happened is react-snap died halfway through crawling, and every route it didn't reach got written out as the raw client-side-rendered shell - an empty <div id="root"></div> with no server-rendered content for crawlers to read.

We deployed exactly this once: build "succeeded," but roughly two-thirds of our blog posts and even the homepage went out as empty shells. Nothing in the deploy pipeline flagged it. The first signal was indexing quality dropping.

What we do now, every deploy

  • Fully quit Chrome before building. Not just close windows - osascript -e 'quit app "Google Chrome"' (macOS), and if a "save tabs?" dialog blocks it, pkill -9 -f "Google Chrome". Tabs restore fine on reopen.
  • Check the crawl count in the build log. It should say crawled 168 out of 168 (or whatever your total route count is). Anything less, or an exit 137, means the build is not safe to deploy.
  • Grep the output for real content, not just file existence:
    grep -oc "Your Homepage H1 Text" build/index.html # should be >=1
    ls build/blog/post/ | wc -l # should match your post count
    
  • Never re-run react-snap on an existing build/ directory. It drops a 200.html marker file and will refuse with "can not run react-snap twice." Always start from a fresh npm run build.

Why this matters more than it looks

A CSR shell isn't a 404 - it's a 200 with valid (if minimal) HTML. Nothing in a typical deploy pipeline treats that as a failure. Search engines and ad reviewers, on the other hand, absolutely notice: an empty shell reads as thin or fabricated content, which is exactly the kind of thing that gets a site flagged for low-value content - not a ranking penalty you get an obvious warning for.

If you're running react-snap (or any Puppeteer-based prerenderer) against a system browser install rather than a bundled/managed one, treat "is the browser actually free of other load" as a real precondition, and add a content-count assertion as a real gate before you ship - not just a green build.

We run this pipeline for Chatrio, a small anonymous chat app with a ~160-post SEO content layer sitting on top of it.

Comments

No comments yet. Start the discussion.