Crawlers get an empty shell because a client-side routed app sends the same HTML file to every address and builds the page afterward in JavaScript. Google runs that JavaScript and sees the finished page. Clients that never run it read the shell as it arrives, with the same title, the same preview tags, and no content.
We often see this treated as one problem with one fix, usually "switch frameworks." In practice SPA SEO splits into three kinds of problem. Some survive Google's rendering, some affect only crawlers that never render, and some come from the fixes most often recommended for SPAs.
Why an SPA's First HTML Arrives Empty
In a client-side routed SPA, the server does not know which view a URL stands for. It returns one entry file, usually index.html, and the router in the browser decides what to draw. Google's JavaScript SEO guide calls this the app shell model.
The App Shell: A Container and a Script Tag
Open view-source on a strict SPA and you usually find little more than <div id="app"></div> and a bundle reference. The Next.js guide to single-page applications describes the same shape: one HTML file, with every route and data fetch handled in the browser. The script tag itself is ordinary, since W3Techs counts JavaScript on 98.9% of the sites it tracks in September 2026. What matters for SPA SEO is whether the content already sits in the response.
Every Route Ships the Same Head
Because every URL returns the same file, every URL also returns the same <head>. The title, description, Open Graph tags, and canonical in the first response are identical on /pricing and /blog/any-post until JavaScript rewrites them.
Google reads the rewritten head after rendering, but its meta tag documentation asks you to avoid changing meta tags with JavaScript whenever possible. A shared head also turns template choices into site-wide ones. A noindex in the shell applies to every route. A fixed canonical in the shell, plus a second one added by JavaScript, leaves two conflicting tags on the page.
Google Renders the Shell, and Three SPA Problems Remain
Google queues every page that returns a 200 status code for rendering. In a 2024 study by Vercel and MERJ on nextjs.org, the median wait between crawl and finished render was 10 seconds. Rendering still costs more than fetching HTML, which matters on large sites where crawl budget is a real limit.
Two conditions stop rendering from helping. A noindex in the first HTML may make Google skip rendering, so JavaScript never gets to remove it. Google also renders only what it may fetch, so blocking a framework's script folder leaves a client-rendered page empty.
Hash Routes Are One URL to Google
A view at /#/products is, for Google, the same document as /, because the fragment never reaches the server. Google asks you not to load different content through fragments, and notes that the AJAX crawling scheme has been deprecated since 2015. Use the History API with real paths such as /products, and make the server answer those paths directly.
Links Without an href May Not Be Followed
Google follows a link reliably when it is an <a> element with an href, including links inserted by JavaScript. The patterns below are the ones it lists as unreliable:
<a href="/products">Products</a> <!-- crawlable -->
<a routerLink="products/category">…</a> <!-- no href -->
<span href="/products">…</span> <!-- not an <a> element -->
<a onclick="goto('/products')">…</a> <!-- address only in a handler -->Google may still attempt to parse them, which falls short of a promise. Check the rendered HTML, because your router component decides the final markup.
Every Missing URL Returns 200, a Soft 404
An SPA that handles errors in the browser has already sent 200 by the time it learns a product does not exist. Google renders that page, can report a soft 404, and warns that such error pages may get indexed. Its two exits are a JavaScript redirect to a URL that returns a real 404, or a noindex tag added by JavaScript. The second exit works because the tag is added after load rather than removed.
Who Reads the Empty Shell: Clients That Skip Rendering
The clients that request an SPA behave differently, so "search engines struggle with JavaScript" is too coarse to act on. The table sorts them by what they read.
Link Previews and Open Graph Tags
Link preview bots read Open Graph tags from the HTML they fetch, and each platform limits that fetch. Meta wants the tags before the first 1 MB and shows no preview if the crawl takes more than a few seconds. Slack's bot fetches as little of the page as it can, and LinkedIn requires four og: tags. None of them documents whether its bot runs JavaScript, so we check what each one read in Meta's Sharing Debugger and LinkedIn's Post Inspector.
AI Crawlers Read the First Response
In December 2024, Vercel and MERJ observed that none of the major AI crawlers rendered JavaScript, GPTBot and ClaudeBot included. Google's AI Overviews have no additional requirements beyond being indexed and eligible for a snippet. On Google's side, the question comes down to Googlebot's rendering.
Bing Warns Against Critical Content Behind Client-Side Rendering
Bingbot renders pages, yet Bing's Webmaster Guidelines list hiding critical content behind client-side rendering as a practice to avoid. Content that cannot be reliably rendered may miss the index and Copilot's grounding results. DuckDuckGo takes most of its traditional links from Bing, so we expect the same limit to carry over.
Common SPA Fixes Often Hide the Problem From Bots Only
The fixes most often recommended for SPAs detect the bot and give it something else. We describe two published developer setups without naming them, because the mechanisms matter more than the authors.
Redirecting Bots by User-Agent With a 301
One setup matches crawler user-agents in .htaccess and sends them with a 301 to a lightweight /api/meta.php page. Google's redirect documentation treats a redirect as a signal that the target should be canonical. The rule's Google(.*) pattern catches Googlebot too, so Google keeps landing on the API address while users stay on the article. By our reading, the setup trades a preview problem for a canonical problem, though we have not measured it on a live site.
Bot-Only Prerendering: Stale Snapshots, Short Lists, Wrong Files
A second setup saves each listed route as HTML at build time, and nginx serves those files only to user-agents on a bot list. Build-time HTML is static rendering, which Google recommends. Serving it only to bots makes it dynamic rendering, which Google calls a workaround and not a recommended solution. Reading the code, we found three weaknesses:
- Stale snapshots. Bots keep the build-time version while users get updates, which strains Google's "similar content" limit.
- A hand-kept bot list. The list misses Google-InspectionTool, which Google's testing tools send, and Lighthouse dropped the identifier the list matched in version 10.0.0.
- A file the server never finds. The route /about is saved as about.html, but the nginx rule tries only $uri and $uri/ before falling back to /index.html.
The bot therefore receives the prerendered home page, with a 200, on every route. The example in the nginx documentation adds the missing piece and makes unlisted routes return 404:
try_files $uri $uri/index.html $uri.html =404;These findings come from reading the code and the nginx documentation. We have not run the setup ourselves.
The Lasting Fix: Content and Head in Every Route's First HTML
One principle covers every client in the table: send everyone the same HTML at the same URL, with the route's content and head inside it. Google recommends server-side rendering, static rendering, or hydration. Part of its reasoning is that not all bots can run JavaScript. With that in place, the bot list and the redirect have nothing left to do.
Three Paths: SSR, Static Generation, and Prerendering for Everyone
Server-side rendering (SSR) builds the HTML on each request. Static site generation (SSG) builds it before any request, and web.dev's Rendering on the Web covers the trade-offs between them. A build-time prerender belongs with SSG as long as the same files go to every visitor. We suggest SSR for request-dependent pages, and SSG or prerendering for pages that change only when you publish.
Where React and Next.js Now Point
The React team deprecated Create React App for new apps on February 14, 2025, and now recommends starting with a framework. The same post calls it "a common misunderstanding" that server rendering is only for SEO. In the Next.js App Router, a direct visit to a route receives server-rendered HTML, and Googlebot requests every URL that way.
A 'use client' directive does not empty that HTML, because Next.js prerenders client components on the server. Fetching content in the browser, or setting ssr: false, does empty it. For static hosting, output: 'export' writes one HTML file per route. The Next.js docs frame all of this as speed, and the search consequences are our reading.
How to Test What Bots See: A Five-Step SPA SEO Check
Each check stands in for a different client, so we run all five on a route other than the home page:
- Open view-source or run curl on the URL. The response is what a client that never renders receives, head tags included.
- In Google Search Console, open URL Inspection and select View crawled page to see what Googlebot fetched and rendered.
- Run the live test knowing that it arrives as Google-InspectionTool. On a site that varies content by user-agent, it can differ from what Googlebot gets.
- Paste the URL into Meta's Sharing Debugger and LinkedIn's Post Inspector to see the tags each preview bot read.
- Request an address that does not exist with curl -I and confirm that the status code is 404.
A site: search with a quoted sentence from the page is a useful positive signal, but an empty result proves nothing. Google's site: operator documentation says indexed URLs are not guaranteed to appear. The Mobile-Friendly Test is gone too, retired by Google on December 1, 2023. If a route passes the first check and still sits outside the index, look at the gap between crawling and indexing next.
Frequently Asked Questions
Can Google index a single-page application?
Yes. Google renders SPA pages that return a 200 status code and indexes the rendered content. Hash-based routes, links without an href, and error views that return 200 still need fixing.
Do I need server-side rendering for SPA SEO?
Not for Google, which renders client-side content. For clients that never render, and for route-specific head tags in the first response, you need SSR, static generation, or prerendering served to every visitor.
Why does every link from my SPA show the same preview?
Every route returns the same index.html, so the first response carries the same Open Graph tags on every URL. Meta's Sharing Debugger shows which tags its crawler actually read.
Is dynamic rendering still a good fix for an SPA?
No. Google's documentation calls dynamic rendering a workaround and not a recommended solution, because it adds complexity and resource requirements. Serving the same prerendered HTML to every visitor removes the need for it.
Why does Google Analytics 4 undercount page views on my SPA?
Route changes in an SPA do not reload the page, so a tag that fires on load counts only the first view. Turn on the GA4 enhanced measurement setting that also sends page_view on browser history events: pushState, popState, and replaceState.
Sources and Further Reading
- Google Search Central, "Understand the JavaScript SEO basics", https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics (last updated March 4, 2026; accessed September 16, 2026)
- Google Search Central, "Fix Search-related JavaScript problems", https://developers.google.com/search/docs/crawling-indexing/javascript/fix-search-javascript (last updated December 18, 2025; accessed September 16, 2026)
- Google Search Central, "Meta tags and attributes that Google supports", https://developers.google.com/search/docs/crawling-indexing/special-tags (accessed September 16, 2026)
- Google Search Central, "Link best practices for Google", https://developers.google.com/search/docs/crawling-indexing/links-crawlable (last updated December 10, 2025; accessed September 16, 2026)
- Google Search Central, "Redirects and Google Search", https://developers.google.com/search/docs/crawling-indexing/301-redirects (last updated April 14, 2026; accessed September 16, 2026)
- Google Search Central, "Dynamic rendering as a workaround", https://developers.google.com/search/docs/crawling-indexing/javascript/dynamic-rendering (last updated December 10, 2025; accessed September 16, 2026)
- Google Search Central, "AI features and your website", https://developers.google.com/search/docs/appearance/ai-features (accessed September 16, 2026)
- Google Search Central, "site: search operator", https://developers.google.com/search/docs/monitor-debug/search-operators/all-search-site (accessed September 16, 2026)
- Google Search Central Blog, "The Search Console mobile friendly testing tool (retired)", update note of December 1, 2023, https://developers.google.com/search/blog/2016/05/a-new-mobile-friendly-testing-tool (accessed September 16, 2026)
- Google, "Google common crawlers", https://developers.google.com/crawling/docs/crawlers-fetchers/google-common-crawlers (last updated July 14, 2026; accessed September 16, 2026)
- Google Analytics Help, "[GA4] Enhanced measurement events", https://support.google.com/analytics/answer/9216061 (accessed September 16, 2026)
- Bing Webmaster Tools, "Bing Webmaster Guidelines", https://www.bing.com/webmasters/help/webmaster-guidelines-30fba23a (accessed September 16, 2026)
- DuckDuckGo Help, "Results sources", https://duckduckgo.com/duckduckgo-help-pages/results/sources/ (accessed September 16, 2026)
- Apple, "About Applebot", https://support.apple.com/en-us/119829 (accessed September 16, 2026)
- Meta for Developers, "Meta Web Crawlers", https://developers.facebook.com/docs/sharing/webmasters/web-crawlers (updated May 21, 2026; accessed September 16, 2026)
- LinkedIn Help, answer a521928 on Open Graph tags for shared links, https://www.linkedin.com/help/linkedin/answer/a521928 (accessed September 16, 2026)
- Slack API documentation, Slackbot robots page, https://api.slack.com/robots (accessed September 16, 2026)
- Giacomo Zecchini, Alice Alexandra Moore, Ryan Siddle, and Malte Ubl, "How Google handles JavaScript throughout the indexing process", Vercel, July 31, 2024, https://vercel.com/blog/how-google-handles-javascript-throughout-the-indexing-process (accessed September 16, 2026)
- Vercel and MERJ, "The rise of the AI crawler", Vercel, December 17, 2024, https://vercel.com/blog/the-rise-of-the-ai-crawler (accessed September 16, 2026)
- web.dev, "Rendering on the Web", last updated January 5, 2026, https://web.dev/articles/rendering-on-the-web (accessed September 16, 2026)
- Matt Carroll and Ricky Hanlon, "Sunsetting Create React App", React Blog, February 14, 2025, https://react.dev/blog/2025/02/14/sunsetting-create-react-app (accessed September 16, 2026)
- Next.js documentation, "Server and Client Components", https://nextjs.org/docs/app/getting-started/server-and-client-components (last updated August 25, 2026; accessed September 16, 2026)
- Next.js documentation, "Linking and Navigating", https://nextjs.org/docs/app/getting-started/linking-and-navigating (last updated August 25, 2026; accessed September 16, 2026)
- Next.js documentation, "How to build single-page applications with Next.js", https://nextjs.org/docs/app/guides/single-page-applications (last updated August 25, 2026; accessed September 16, 2026)
- nginx, "Module ngx_http_core_module: try_files", https://nginx.org/en/docs/http/ngx_http_core_module.html#try_files (accessed September 16, 2026)
- Lighthouse changelog, version 10.0.0, February 9, 2023, https://github.com/GoogleChrome/lighthouse/releases/tag/v10.0.0
- W3Techs, "Usage statistics of JavaScript as client-side programming language on websites", September 2026, https://w3techs.com/technologies/details/cp-javascript (accessed September 16, 2026)
