The site is live, it looks good, it is fast in your browser, and still the inner pages do not show up on Google. Before blaming the content or the competition, check something much simpler: what the crawler receives when it requests your page.
What Googlebot receives when the HTML arrives empty
A site built as a SPA, a single page application, in React, Vue or Angular, usually ships HTML like this: a <head> tag, an empty <div id="root"></div>, and a script tag. All of the content, the headings, the text, the navigation links, is created in the browser after the JavaScript downloads and runs.
For a person on a modern Chrome that works fine. For a crawler, the first contact is a document with no text. Googlebot does not give up immediately, but the process now depends on an extra step you do not control.
And it is not only Google. Link previews on WhatsApp, LinkedIn and other platforms normally read the raw HTML and do not run JavaScript. If your meta tags are injected by JavaScript, the shared link goes out with no title and no description.
How Google renders JavaScript and why that delays indexing
Google processes JavaScript pages in two stages. First it crawls and reads the HTML that came from the server. If the content depends on JavaScript, the page enters a render queue, where a Google-controlled Chromium opens the page, runs the scripts, and only then the result goes back for indexing.
That second stage exists and works, but it introduces three concrete risks:
- Delay. Rendering happens when resources are available, not immediately. A new or updated page may take longer to enter the index.
- Silent failure. If a script breaks, if an API request times out, if a resource is blocked in
robots.txt, the content simply does not appear in the render. And nobody tells you. - Third-party dependency. If your content comes from a slow external API, rendering can finish before the content arrives.
None of that happens when the HTML already arrives complete.
How to test what the crawler really sees
There are two tests, and both take under two minutes.
Test 1: raw HTML in the terminal. Request the page the way a crawler would, running nothing:
curl -s https://yoursite.com/page | grep "<h2"
If it comes back empty, your content is not in the HTML. Swap the grep for a piece of text you know exists on the page to confirm. An even blunter test is measuring the document size:
curl -s https://yoursite.com/page | wc -c
An 800-byte HTML file for a two-thousand-word page is the classic symptom.
Test 2: the URL Inspection tool in Search Console. Paste the URL, click test live URL, then open the crawled HTML view. There you see exactly what Google managed to assemble, including the screenshot of the rendered page and the list of resources it could not load. If you have not set Search Console up yet, start with our technical SEO checklist.
CSR, SSR and SSG: the difference in one sentence each
- CSR, client-side rendering. The server sends empty HTML and the browser assembles the page. Simple to host, bad for crawlers.
- SSR, server-side rendering. On every request, the server runs the code and returns finished HTML. Great for content that changes per visit, but it requires a Node server running all the time.
- SSG, static generation. The HTML for each page is generated once, at build time, and served as a file. Fastest, cheapest, and safest for pages whose content does not change every second.
For a company site, a landing page or a blog, SSG is almost always the right call. The content changes a few times a month, not on every request.
There is also the hybrid path, which is what most well-built sites use today: static HTML for the first visit and JavaScript taking over navigation once the page has loaded. The visitor gets immediate content, the crawler gets immediate content, and internal navigation still avoids full page reloads. That is what the next section is about.
Pre-rendering a SPA with Playwright
Rewriting the project in a framework with built-in SSG is not always an option. It is also not always necessary. On our own site, albseven.com, we kept the application in React with Vite and added a pre-rendering step to the build process. The script lives in scripts/prerender.mjs and does the following:
- After the normal build, it serves the
dist/folder on a local server withvite preview. - It opens a Chromium instance through Playwright.
- It visits every route in the page list, one by one.
- It waits for React to finish mounting and for the content to actually appear.
- It captures the final HTML and saves it to
dist/<route>.html. - It closes the browser and shuts the preview server down.
The result is a deploy folder that still contains the same SPA for internal navigation, but that now also has a complete HTML file per route. First-time visitors receive finished HTML. After that, React takes over and navigation stays instant.
Two details make the difference in that script. The first is the wait: waiting for the load event alone is not enough, because React has not mounted anything yet. You have to wait for a selector that only exists after rendering. The second is the route list, which must be generated from the same source that feeds the router, otherwise a new page joins the site and quietly stays out of pre-rendering.
A third concern is what happens to data coming from an API. If the page fetches content from a server as it loads, pre-rendering captures the state of that instant and freezes it into the file. For content that changes often, that calls for a deliberate decision: either the build runs on every publish, or the page assumes content will be refreshed in the browser afterwards, keeping the HTML as a valid initial version for the crawler.
The .htaccess rewrite that keeps the visible URL
Generating about.html is pointless if the public URL has to become /about.html. The clean URL is the one already indexed, the one people share, and the one you want to keep. The answer is an internal rewrite on the server: the address bar still shows /about, but Apache serves the matching .html file.
The core idea, in .htaccess, is: if a file exists with the same name plus the .html extension, serve that file. Only when it does not exist does the request fall through to the SPA index.html.
RewriteCond %{REQUEST_FILENAME}.html -f
RewriteRule ^(.+)$ $1.html [L]
Note the [L] and the absence of [R]. There is no redirect. The browser never learns that a .html file sits behind the URL. It is an internal rewrite, not a redirect, and that distinction is what preserves the URL and avoids an extra hop on every visit.
Pitfalls: self-referencing canonical, hreflang and real 404s
Canonical. With two possible ways to reach the same thing, a self-referencing canonical on every page settles any ambiguity. Each pre-rendered HTML file should point to its own clean URL, in the same shape you use in the sitemap: same protocol, same domain, trailing slash or not, always consistent.
hreflang. If the site has another language version, such as /blog/article and /en/blog/article, each version has to declare every alternative, including itself. Language markup applied only by JavaScript usually does not survive in the raw HTML, so it has to be part of the pre-render.
Real 404s. This is the most common and most invisible mistake. A SPA answers 200 for any path, including the ones that do not exist, and shows an error screen in the browser. To Google that is a soft 404: a page claiming everything is fine while carrying no valid content. The server has to return a real 404 status for non-existent routes, with ErrorDocument pointing at your error page.
If you already have traffic and you are about to change this structure, read how to redesign without losing SEO first. And if you want to see this approach on real sites, our portfolio shows projects built this way.
If your React site is invisible to Google and you want to know exactly where the bottleneck is, talk to ALB Seven. We run this diagnosis often.