Skip to content
Lovable Field Guide
Start here

SSR vs SPA for SEO: what actually changes

SSR vs SPA SEO, settled: Googlebot renders your SPA, but unfurlers and AI crawlers read raw bytes, and SSR only helps if your data is fetched in a route loader.

20 min read

Googlebot renders your single-page app. It has done for years, and the data says it usually takes seconds. What SSR can change is everything else that reads your HTML: link unfurlers, AI crawlers, and Bing, none of which are known to run your JavaScript reliably. SSR renders your components and only includes data that a route loader fetched, so whether those agents see your content depends on where the page fetches it.

If you came here to find out whether client-side rendering is killing your rankings in Google, the honest answer is probably not. If you came here because your links look blank in Slack and ChatGPT has never heard of your product, you are in the right place.

The short version

  • Google renders JS. Crawl, render, index. Median render delay ~10 seconds in the best public measurement. The “Google can’t see React” claim is obsolete.
  • Almost nothing else appears to. Slack’s own docs describe a Range-header fetch, Meta documents a 1MB byte cutoff, and a December 2024 Vercel/MERJ measurement found no major AI crawler except Applebot rendering JavaScript.
  • SSR only includes data that a route loader fetched. A page can be fully server-rendered and still ship an empty skeleton, because effects and un-prefetched queries run on the client. Where the fetch lives decides what ends up in the HTML.
  • Bing is its own category. It claims JS capability, admits it can’t do it at scale, and actively recommends dynamic rendering: the opposite of Google’s position.
  • The rendering mode you pick is a tradeoff between freshness, cost and route cardinality.
  • Core Web Vitals that affect ranking still come from the initial load, even in 2026. That is exactly the load SSR and prerendering improve.

If you are on Lovable, read the Lovable SEO overview first, because which of these paragraphs applies to you depends on whether your project predates 13 May 2026, and, on either stack, on where your pages fetch their data.

What Google actually does with your JavaScript

Google’s documentation describes three phases: crawling, rendering, indexing. It does not use the phrase “two waves of indexing” anywhere. That phrase comes from a 2018 Google I/O talk and it has been quoted in SEO posts ever since, usually alongside a claim that the second wave takes days or weeks.

Here is what the docs actually say. Googlebot queues pages for both crawling and rendering, and it is not obvious from the outside which queue a page is sitting in. On the delay, Google’s wording is that a page “may stay on this queue for a few seconds, but it can take longer than that.” Vercel and MERJ put numbers on that by matching over 37,000 server logs to rendering beacons:

Percentile Render delay
p25 ~4s
p50 10s
p75 26s
p90 ~3 hours
p95 ~6 hours
p99 ~18 hours

The tail is real and it matters for news and time-sensitive pages. The median does not. In the same study, across more than 100,000 Googlebot fetches on nextjs.org (excluding error statuses and non-indexable URLs), every HTML page resulted in a full-page render, and the researchers found no evidence that JS-heavy pages were crawled less.

Three properties of that renderer are worth knowing, because they break things in ways that look like “Google hates SPAs”:

  1. The renderer is evergreen Chromium. Google’s docs say the Chrome version in Googlebot’s user agent tracks the latest Chromium release used by Googlebot. You do not need to worry about ES2015 support. You do need to worry about a runtime error, because a page that throws before it paints renders as nothing.
  2. It is stateless between page loads. Google’s Web Rendering Service does not retain state across page loads: Local Storage, Session Storage and cookies are cleared. Any SPA that gates content behind a token in localStorage, or behind a “first visit” flag, hands Googlebot the logged-out empty state on every single URL.
  3. There is a size ceiling. Googlebot crawls the first 2MB of a supported file type, inside a general crawler default of the first 15MB of a file, with anything beyond ignored. The limits apply to uncompressed data. This bites SPAs that inline a huge JSON state blob into index.html: content past the cut simply does not exist.

The crawlers that never render

This is the part that decides whether SSR is worth doing, and it has almost nothing to do with Google.

Slack: proven by its own docs

Slack states that Slackbot-LinkExpanding fetches as little of the page as it can, using HTTP Range headers, to extract oEmbed, Twitter Card and Open Graph tags. A Range request pulls a partial byte range of the HTML document. There is no mechanism by which that boots a JavaScript runtime, downloads your bundle and waits for React to mount. This is the single cleanest first-party proof that link preview bots do not execute JavaScript.

Facebook reads a byte window of raw HTML

Meta’s crawler docs say Open Graph properties need to appear before the first 1MB of your document or they get cut off. A byte-offset cutoff on the source document is the signature of a streaming HTML parser; a headless browser has no such concept, because it renders the whole DOM. Meta never says “we do not run JavaScript” in so many words, so state it precisely: facebookexternalhit reads the first 1MB of your raw HTML.

LinkedIn: undocumented, but requires four tags

LinkedIn’s help pages require og:title, og:description, og:image and og:url, with images at a minimum 1200x627, and say nothing at all about JavaScript, rendering or caching. “LinkedInBot does not run JS” is strong community consensus rather than a LinkedIn statement, so treat it as the safe assumption and use the Post Inspector to force a re-scrape after a fix.

AI crawlers: the December 2024 measurement

Vercel and MERJ reported that none of the major AI crawlers render JavaScript, naming OpenAI’s OAI-SearchBot, ChatGPT-User and GPTBot, Anthropic’s ClaudeBot, Meta-ExternalAgent, ByteDance’s Bytespider, and PerplexityBot. They do fetch .js files and read them as text without executing them: 11.50% of GPTBot’s requests and 23.84% of ClaudeBot’s were JavaScript files.

Agent Executes JavaScript? Basis
Googlebot Yes, evergreen Chromium Google docs
Applebot Yes: “may render the content of your website within a browser” Apple docs
Bingbot Sometimes, not at scale Bing’s own statement
Slackbot-LinkExpanding No: Range-request fetch Slack docs
facebookexternalhit No (inferred from a 1MB byte cutoff) Meta docs
LinkedInBot Assume no Community consensus, undocumented
GPTBot / OAI-SearchBot / ChatGPT-User No Vercel/MERJ, Dec 2024
ClaudeBot No Vercel/MERJ, Dec 2024
PerplexityBot No Vercel/MERJ, Dec 2024
Bytespider No Vercel/MERJ, Dec 2024
Google-Extended Issues no requests at all Google docs

Two footnotes on that table, both of which most listicles get wrong.

Applebot is the exception. Apple’s own documentation says Applebot may render the content of your website within a browser, and warns that if your JavaScript is blocked in robots.txt it may not be able to render properly. “No AI crawler runs JS” is a useful heuristic and a false absolute.

Google-Extended is not a crawler. Google’s docs are explicit: it has no separate HTTP request user agent string, and the robots.txt token is used in a control capacity only. Asking whether Google-Extended executes JavaScript is a malformed question. It never makes a request.

That study also measured how much AI crawl budget is wasted: 34.82% of GPTBot fetches hit 404s and 14.36% hit redirects, and ClaudeBot logged 34.16% 404s, against Googlebot’s 8.22% and 1.49%. Client-side redirects are part of why. Googlebot follows window.location.href because it renders; every non-rendering agent walks into a dead end. Use a server 301 or 308 for anything that must survive a crawler.

Bing is not a small Google

Bing’s standing guidance is the inverse of Google’s, and it has been for years. Bing states that it is difficult for bingbot to process JavaScript at scale on every page of every website while keeping HTTP requests down, and then recommends dynamic rendering as an alternative for JS-heavy sites.

Empirical testing backs the “unreliable” reading rather than “incapable”: across roughly 20 tested JS sites, most showed no sign of JS indexing, with no obvious pattern separating the successes from the failures, though some React sites did return snippets containing render-only content.

One detail from that testing is worth more to you than the headline: Bing consistently took <title> from the initial HTML even when it appeared to render the body. A SPA that ships one <title> in index.html for every route will have every page titled identically in Bing, no matter how good your client-side title management is. That is also how your pages get titled in Copilot, which inherits Bing’s index.

What each rendering mode actually serves

Now the decision. These are the real axes, not “SEO-friendly: yes/no”.

Mode What a non-rendering crawler receives Freshness Runtime cost Route cardinality Main liability
Client-rendered SPA An empty root div and one set of meta tags for the whole site Instant None Unlimited Invisible to every non-rendering agent
Build-time SSG / prerender Complete HTML per route Requires a rebuild None (static files) Must be enumerable Rebuild latency; no per-user routes
Edge UA prerender Complete HTML per route, from a cache Cache TTL Per render Unlimited Snapshots need refreshing when content changes (a managed service does this)
Full SSR The component tree, plus whatever the route loader fetched Instant A server on every request Unlimited Anything fetched after mount is absent; isomorphic-code constraints; hydration cost
ISR Complete HTML per route Stale-while-revalidate Amortized Unlimited Needs a framework that supports it

A few notes that the table flattens.

SSG is underrated. If your routes are enumerable (marketing pages, docs, a blog) build-time prerendering beats everything else on every axis that matters: zero runtime cost, best LCP, CDN-cacheable, and correct for every crawler on earth with no user-agent check. The cost is a rebuild to publish a change.

ISR gives you SSG economics with freshness, via stale-while-revalidate. Vercel’s implementation (docs last updated 28 August 2026) keeps a durable cache for 31 days or until revalidated, collapses concurrent requests on a miss, purges globally within 300ms, and on a failed revalidation keeps serving stale content. It reaches you through Next.js, SvelteKit, Nuxt, Astro or Gatsby DSG, and not through a plain Vite SPA unless you adopt one of them. TanStack Start has no ISR mode at all: there is no isr, swr, revalidate or staleMaxAge key in its Vite plugin config, and what its docs label ISR is hand-written per-route Cache-Control headers on top of build-time prerendering.

Hash routing forecloses all of this. Everything after # is stripped by the browser and never transmitted in the HTTP request, so no server, CDN, edge worker or prerender middleware can know that /#/products was requested. Moving to BrowserRouter with a catch-all rewrite comes first; nothing else works without it.

SSR only includes data that a loader fetched

That is the sentence the rest of this section exists to defend, and it holds for every SSR framework: Next, Remix, Nuxt, SvelteKit, TanStack Start. Server-side rendering runs your components on the server and serializes what they returned. It does not run your effects, and it does not wait for a fetch that begins after the component mounts.

React’s documentation says it plainly: “Effects only run on the client. They don’t run during server rendering.” The server therefore renders each component in its initial state, which for a data-driven component is the loading branch. The HTML ships the spinner.

TanStack Query’s SSR guide describes the same boundary from the other side: queries you do not prefetch “wont be server rendered, instead they will be fetched on the client after the application is interactive.” And TanStack Start’s Query guide states the positive case in a single line: “The route awaits the query for content that must appear in the initial HTML.”

Put those together and you get the fact that most migration write-ups skip: “SSR is enabled” and “my content is in the HTML” are independent facts. A page can be one hundred percent server-rendered and contain none of its dynamically loaded content (CMS posts, product pages, directory listings: anything that isn’t written into the page’s source code). Nothing is malfunctioning when that happens: the HTML faithfully reproduces what the component tree looked like on the server, and on the server it was still loading.

Where the fetch lives is what decides it.

Where the data is fetched In the initial HTML?
Route loader, createServerFn, or a server component Yes: the server awaits it and serializes the result
useEffect + setState No: effects do not run during server rendering
useQuery with no server-side prefetch No: fetched on the client once the app is interactive
A fetch triggered by a click, a scroll, or an intersection observer No, and no crawler is going to trigger it either

Frameworks change the API name and nothing else. In Next.js, data fetched in a server component lands in the HTML while the same query inside a 'use client' component’s useEffect does not. Lovable’s own announcement of its TanStack Start default is worded with the distinction intact: the server “runs the React tree, executes the loaders, and streams the result.” Loaders. That is the hinge, and it is the word to look for in any vendor’s SSR claim.

Look for it in Lovable-generated code and you find out how rare it is. In a study of 82 Lovable apps, only 4.4% of page routes loaded their data in a route loader. Nearly half fetched it inside the component with useEffect or useQuery instead, and most of the apps queried Supabase directly from the browser.

That is this section’s argument as a measurement rather than a mechanism, and the direction matters more than the decimals. The markup on those sites is genuinely server-rendered: fetch one with a normal browser user agent and you get tens of kilobytes of real HTML, headings and lists included. The dynamically loaded content mostly is not in it. The clearest single case in the study is a public blog post route with no loader: and no head:: a useEffect queries the posts table and loading is initialized to true, so the server-rendered HTML of every post on that site is a spinner, with no title, description or og tags.

Nobody notices on a normal visit, which is why this survives code review. A human never sees the gap, because the browser runs the effect a few hundred milliseconds later and the page fills in. Googlebot usually does not see it either: it renders, subject to the queue delay above. Slack’s unfurler and facebookexternalhit see it every time, going by each vendor’s own description of how it fetches; so did GPTBot, ClaudeBot and PerplexityBot in the Vercel/MERJ measurement above. They stop at the bytes.

TanStack Start’s SPA mode is the mechanism at its most literal: it prerenders a shell containing your pending fallback, so you get a 200 response full of loading spinner. That passes a naive check (server-rendered, right status code, HTML that is not empty) and indexes as nothing. I have written up what each TanStack Start rendering mode serves a crawler separately.

Check your own page in thirty seconds

Run this against a page with dynamically loaded content. Not the homepage: a hard-coded hero renders on the server no matter how the rest of the app fetches.

curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)' \
  https://acme.com/products > /tmp/page.html

# Pick a phrase that isn't in your source code:
grep -qiF 'Ceramic Pour-Over Kettle' /tmp/page.html \
  && echo 'server-rendered' || echo 'client-fetched: crawlers never see it'

Four caveats, or you will draw the wrong conclusion from the result:

  1. Your search phrase has to be one contiguous run of text. Markup splits phrases across elements, and grep sees the markup.
  2. If you count words rather than grepping for a phrase, strip <script> and <style> first. Otherwise you are counting inline CSS and a serialized state blob.
  3. On Lovable’s React + Vite stack this returns the SPA shell whatever user agent you send, and what verified crawlers receive can’t be verified from outside.
  4. If you re-run the request to tell per-request rendering from caching, identical bytes prove nothing on their own: cached, prerendered and simply deterministic all look alike. Read cf-cache-status and age in the response headers.

Is user-agent prerendering cloaking?

No, under the stated rules of both engines, provided the content matches. This question stops more teams than it should, so here is the precise position.

Google’s dynamic rendering page says it “was a workaround and not a recommended solution, because it creates additional complexities and resource requirements.” Note the past tense: that is the current wording. Google names server-side rendering, static rendering and hydration as the alternatives. There is no deprecation date and no statement that it is penalized.

Cloaking is defined elsewhere, in the spam policy: presenting different content to users and search engines with the intent to manipulate rankings and mislead users. The examples given are showing travel content to engines and drug content to users, and inserting keywords only when the requester is a search engine. Intent to mislead plus content divergence is the test, and that page does not mention dynamic rendering at all.

Bing is more direct: it says that as long as you make a good-faith effort to return the same content to all visitors, with the only difference being that bots get it server-rendered and users get it client-rendered, that is acceptable and not considered cloaking.

Doing it yourself

You do not need a vendor for any of this. Here is the full manual path, cheapest first.

1. Prove what crawlers currently receive

Before changing anything, get the evidence. Run this against three routes:

for route in / /pricing /blog/hello-world; do
  echo "=== $route ==="
  curl -sL -A 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)' \
    "https://acme.com$route" \
    | grep -oE '<(title>[^<]*|meta[^>]*(og:|twitter:)[^>]*|link[^>]*canonical[^>]*)>'
done

# And confirm a nonexistent route is a real 404, not a soft 404:
curl -sI -o /dev/null -w '%{http_code}\n' https://acme.com/this-does-not-exist

If og:title is identical across all three routes, your link previews are broken and your Bing titles are duplicated. If the last command prints 200, your catch-all rewrite is manufacturing soft 404s at every misspelled URL.

2. If your routes are enumerable, prerender at build time

This is the correct answer for most marketing sites, and it needs no runtime infrastructure. On Lovable it isn’t an option. Lovable runs its own heavily modified Vite setup, and its build servers aren’t meant for heavy jobs like prerendering: try it and you can crash the project. It only works once you export the code and build it on your own machine or CI. React Router in framework mode takes a prerender() returning the URLs to bake:

// react-router.config.ts: SSR everywhere, plus static HTML for hot routes.
import type { Config } from '@react-router/dev/config'

export default {
  ssr: true,
  async prerender() {
    const posts = await fetch('https://api.acme.com/posts').then((r) => r.json())
    return ['/', '/pricing', ...posts.map((p: { slug: string }) => `/blog/${p.slug}`)]
  },
} satisfies Config

TanStack Start has an equivalent prerender option in its Vite plugin. It is off by default, and it has one trap: auto-discovery skips routes with path params like /users/$userId, so list those explicitly. Prerendering also inherits the rule above: a prerendered route captures whatever the loader resolved at build time, and nothing a component hook fetches later.

3. If routes are unbounded, do the UA split at the edge

When you cannot enumerate routes and cannot adopt SSR, a worker in front of your static host is the pragmatic option. The guards matter more than the routing:

const BOT = /googlebot|bingbot|applebot|facebookexternalhit|meta-externalagent|twitterbot|linkedinbot|slackbot|discordbot|whatsapp|gptbot|oai-searchbot|chatgpt-user|claudebot|perplexitybot|bytespider|ccbot|amazonbot/i
const ASSET = /\.(js|mjs|css|json|xml|txt|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|mp4|pdf)$/i

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)
    const ua = request.headers.get('user-agent') || ''
    const isGet = request.method === 'GET' || request.method === 'HEAD'
    // Recursion guard: the renderer fetches this origin back. Pass its requests
    // straight through, matching whatever header or user agent it sends.
    const selfFetch = request.headers.has('x-prerender')

    if (!ua || !isGet || selfFetch || ASSET.test(url.pathname) || !BOT.test(ua)) {
      return fetch(request) // humans and assets go straight to the SPA
    }

    const cache = caches.default
    const key = new Request(url.origin + url.pathname + url.search, { method: 'GET' })
    const hit = await cache.match(key)
    if (hit) return hit

    let res
    try {
      res = await fetch(
        `https://encited.com/api/prerender/render?url=${encodeURIComponent(url.toString())}`,
        {
          headers: { Authorization: `Bearer ${env.ENCITED_API_KEY}` },
          signal: AbortSignal.timeout(15_000),
          redirect: 'manual',
        },
      )
    } catch {
      return fetch(request) // FAIL OPEN: a dead renderer must never 5xx your site
    }
    // 301: a redirect you configured, pass it to the client.
    // 304: pre-rendering didn't apply to this URL, fall back to the origin.
    if (res.status === 301) return res
    if (res.status !== 200) return fetch(request)

    const out = new Response(res.body, res)
    out.headers.set('Cache-Control', 'public, max-age=600, s-maxage=86400')
    out.headers.set('Vary', 'User-Agent')
    ctx.waitUntil(cache.put(key, out.clone()))
    return out
  },
}

Four things in there are non-negotiable: skip non-GET methods, skip static assets (rendering your own JS bundle is how you burn a render quota in an afternoon), carry a recursion guard header, and fail open. The open-source prerender-node middleware does all four, and its source is a good spec to read.

Cloudflare ships no first-party dynamic-rendering product, so the choice is between proxying to a hosted renderer like Encited (as the sample above does), driving Cloudflare’s Browser Rendering yourself, or, cheapest and most robust, generating per-route HTML at build time and skipping headless Chrome entirely.

4. Recache on deploy

Whatever you build, wire cache invalidation into your deploy pipeline. This is the step teams skip, and it is the one that turns a compliant setup into a divergent one.

Encited also installs without a Worker, as middleware or with two DNS A records. It serves rendered HTML to search crawlers and Markdown to AI agents, and keeps per-visit crawl logs so you can confirm GPTBot actually got your content.

Self-hosting Puppeteer or Playwright is viable if you have the ops appetite, and framework SSG remains the option with no moving parts at all.

Core Web Vitals: what SSR fixes and what it doesn’t

Google is explicit that SPA architecture is not inherently slow. Its wording on web.dev is that there is nothing inherent in the SPA architecture that would prevent a page in an SPA from loading just as quickly, and scoring just as well on Core Web Vitals, as a comparable page in a multi-page app. Its advice is to switch only if you are unhappy with your stack.

What changes with SSR or prerendering is narrower and worth stating exactly: LCP on the first hard navigation improves, because the largest contentful element arrives inside the initial HTML response where the preload scanner can find it, rather than waiting on download, parse, execute, hydrate, fetch, paint.

INP can get worse. A prerendered page paints early and hydrates later, and during that gap it looks interactive and isn’t: clicks land on nothing. Google’s INP thresholds at p75 are 200ms or less for good, 200-500ms for needs improvement, above 500ms for poor. Soft navigations inside an SPA often score better than hard loads here, because there is less JavaScript startup work competing for the main thread.

Then there is the measurement question, which changed in 2026 without changing anything that matters yet. Chrome 151, released August 2026, shipped APIs that can measure Core Web Vitals for SPA route changes: PerformanceSoftNavigation and InteractionContentfulPaint, after a final origin trial across Chrome 147-149. Before that, a route change that updated the URL had no effect on how Core Web Vitals were measured at all. But CrUX has not adopted them: Google’s own FAQ says Chrome has not yet published timeframes for integrating these into the Chrome User Experience Report, and that including SPA route data in CrUX aggregates is still in progress.

The practical consequence for the next year: the Core Web Vitals in your Search Console report still come from hard navigations only. That means the entry page. Which is precisely the load that prerendering and SSR improve, so the measurement gap happens to point the same direction as the fix.

What to do next

  1. Run the curl loop above against three of your routes. Compare og:title. That takes two minutes and tells you whether you have a problem at all.
  2. Check whether a nonexistent URL returns 404 or 200. A 200 is a soft 404 factory.
  3. Count your routes. Enumerable means build-time prerendering once you’re off Lovable hosting, and you are done. Unbounded means SSR, ISR, or an edge split.
  4. If you already run SSR, curl a page with dynamically loaded content and grep the raw HTML for a phrase that isn’t in your source code. If it is missing, the reviews, prices or listings are arriving after hydration and SSR did not solve your problem for the agents that never render: see why link previews stay broken for the same failure in its most visible form.
  5. Whatever you deploy, put cache invalidation in the deploy pipeline before you ship it.

Found something wrong or out of date? Lovable changes fast and we'd rather fix a guide than let it rot.

Disclosure: the team behind this guide also builds Encited, mentioned above.

Frequently asked questions

Does server-side rendering guarantee that crawlers see my content?
No. SSR renders your components. It only includes data that a route loader fetched. React's documentation states that effects only run on the client and do not run during server rendering, and TanStack Query's SSR guide says queries that are not prefetched are fetched on the client after the application is interactive. Data fetched in a route loader or a server function is serialized into the HTML; the same query inside a useEffect or an un-prefetched useQuery is not. Test a page with dynamically loaded content: fetch it with curl and grep for a phrase that exists only in that content.
Can Google index a client-rendered React SPA?
Yes. Googlebot crawls, then renders with an evergreen Chromium, then indexes. A Vercel/MERJ study of over 100,000 Googlebot fetches on nextjs.org found every HTML page resulted in a full render, with a median render-queue delay of about 10 seconds. Rendering is not the reason most SPAs fail in search.
Which crawlers do not execute JavaScript?
Link unfurlers and AI crawlers. Slack's own docs say its unfurler fetches the page with HTTP Range headers, which cannot boot a JS runtime. Meta documents a 1MB byte cutoff for Open Graph parsing. A December 2024 Vercel/MERJ measurement found that GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Meta-ExternalAgent, Bytespider and PerplexityBot render no JavaScript. Apple's Applebot is the documented exception and may render pages in a browser.
Is serving prerendered HTML to bots considered cloaking?
Not under the stated rules of either engine, provided the content matches. Google defines cloaking as presenting different content to users and search engines with intent to manipulate rankings, and its spam policy does not mention dynamic rendering. Bing states that returning server-rendered content to bots and client-rendered content to users is acceptable and not cloaking. The content matches as long as snapshots refresh when your pages change, which a managed service like Encited handles for you.
Does switching from SPA to SSR improve Core Web Vitals?
It improves LCP on the first (hard) navigation, because the largest element arrives in the initial HTML instead of waiting for bundle download, parse, execute, hydrate and fetch: provided that element's data is fetched in a route loader rather than a component hook, since data fetched after mount is not in the HTML at all. It does not automatically improve INP, and hydration can make INP worse by painting a page that is not yet interactive. Google states there is nothing inherent in SPA architecture that prevents good Core Web Vitals.
Does CrUX measure Core Web Vitals for SPA route changes yet?
No. Chrome 151 shipped APIs that can measure soft navigations, but as of September 2026 Chrome has not published a timeframe for integrating them into the Chrome User Experience Report. Ranking-relevant Core Web Vitals still reflect hard navigations only, which means your entry page.

Read next