"Switch to Next.js or Nuxt" is incomplete advice, and it sends teams chasing the wrong fix. Both frameworks normally generate real, content-complete HTML before the browser ever hydrates it - though that HTML may be produced at build time, on demand, or at request time depending on route and deployment configuration, not necessarily rendered fresh on a server for every single request. The actual failure mode - the one that survives a migration to a "proper" SSR framework and still leaves your pricing page blank to a crawler that doesn't execute JavaScript - is content that's fetched or built entirely in the browser, after that initial HTML has already gone out. Below is exactly where that happens in each setup.
What Hydration Actually Is
Hydration is the process of attaching interactivity to HTML that already exists - Next.js's own documentation defines it as the process "for attaching event handlers to the DOM, to make the static HTML interactive," and React's own hydrateRoot reference describes the same mechanism as turning "the initial HTML snapshot from the server into a fully interactive app that runs in the browser." That definition matters: hydration is supposed to start from real HTML. React's own hydration API makes the distinction explicit at the framework level - hydrateRoot() is for attaching to HTML a server already generated, and React's docs say plainly that if your app is "client-rendered with no HTML rendered already," hydrateRoot "is not supported" and you need the client-only createRoot() instead. The crawler-visibility problem shows up when a page is effectively in that second category for the content that matters - when the server ships an empty or near-empty container and JavaScript builds the content from scratch in the browser, rather than making already-present content interactive.
Plain React or Vue (No Framework, No SSR)
A typical client-only single-page app - one whose route content mounts into an otherwise-empty root, the common setup for a legacy Create React App project or a bare Vite+React/Vue starter with no added prerendering - sends little more on first request than static shell HTML and script references: a near-empty container div and a set of script tags. In that configuration, every piece of component-generated content - your pricing table, your feature list, your docs - gets built by JavaScript after the browser downloads and executes those scripts. That's not a property of React, Vue, Vite, or CRA themselves, though - those tools can ship static HTML around the mount point, and teams can add prerendering or multi-page output on top of any of them. Verify the actual response your app sends rather than inferring it from which build tool is in the package.json. (Worth noting: React itself deprecated Create React App for new projects in February 2025, recommending a framework - Next.js, React Router, Expo - or a modern build tool like Vite instead; if you're still shipping a CRA app, that's worth addressing on its own merits, separate from this crawler question.)
This is the clean, total-failure case where it does apply: a crawler that only reads the initial HTML response - which is the observed, tested behavior for GPTBot, ClaudeBot, and PerplexityBot in Vercel's December 2024 tests - fetches that near-empty shell, finds nothing, and stops. Nothing you write into your components exists yet from that crawler's point of view. That's a dated, repeated observation about those specific named agents, not a permanent guarantee any of the three providers has made, and it doesn't extend to every AI-related crawler: Googlebot's rendering pipeline executes JavaScript as part of a documented crawl-render-index process, and this cluster's own comparison page treats Googlebot and Applebot as rendering-capable - so "AI crawler" isn't a single category with one shared rendering behavior. If your SaaS marketing site is genuinely a plain client-only SPA with no rendering layer at all, this failure mode is happening on every page for the non-rendering agents, not just some of them - but re-test periodically, since crawler behavior can change without notice.
Next.js App Router: The Nuance Nobody Explains
This is where most competing advice gets it wrong by oversimplifying. In the App Router, layouts and pages are Server Components by default, and Next.js's own documentation is explicit that adding "use client" to a component does not remove it from the prerendering step: "Client Components and the RSC Payload are used to prerender HTML." On first load, that prerendered HTML "is used to immediately show a fast non-interactive preview of the route" - before any JavaScript has run. In plain terms: a Client Component's markup still participates in that initial HTML, generated ahead of hydration and included in the initial response - whether that HTML was produced at build time, on demand, or at request time depends on the route's own rendering configuration, but the "use client" directive by itself doesn't remove the component from pre-rendering. A crawler that doesn't execute JavaScript can still see it.
So what actually breaks? Data. If a Client Component fetches its own data client-side - a useEffect call, or a data-fetching library used without a server-side prefetch - that fetch hasn't happened yet at the moment the server prerenders the page. The HTML that goes out the door reflects the component's state before that data arrives: a loading spinner, a skeleton, or nothing. A human's browser fetches the data a moment later and fills it in, invisibly fast. A crawler that only reads the initial HTML never sees that second step happen. The bug isn't "we used Client Components." It's "this specific component's content depends on a fetch that only runs in the browser."
Nuxt: Same Shape, Different Syntax
Nuxt's own documentation states the same default posture in different words: "By default, Nuxt uses universal rendering to provide better user experience, performance and to optimize search engine indexing" - meaning a real, content-complete HTML response ships on first request, the same way Next.js's App Router does. The most common failure mode is the Vue equivalent of the Next.js one: fetching data inside onMounted - a lifecycle hook that Vue's own documentation states plainly "is not called during server-side rendering" - instead of useFetch or useAsyncData, Nuxt's data-fetching composables, which Nuxt's docs describe as ensuring that when "an API call is made on the server, the data is forwarded to the client in the payload." A component written with onMounted-triggered data fetching will look correct to every human tester and be functionally blank to a non-rendering crawler, in an otherwise fully SSR-configured Nuxt app.
Using the "right" composable name alone isn't the whole check, though - its configuration matters just as much. useFetch/useAsyncData can still be set to skip the server: Nuxt's docs document a server: false option that, when set, means "the data will not be fetched before hydration is complete," which reintroduces the exact same client-only-content problem through a data-fetching composable that looks correct at a glance. Route-level configuration can do the same thing more broadly: Nuxt's routeRules can set ssr: false for specific paths - an admin dashboard, say - without affecting the rest of the app, and a component on an otherwise-SSR'd page can silently inherit that route's client-only behavior. Nuxt's docs are equally direct about what happens when SSR is turned off entirely, whether globally or per-route: in that mode, "a traditional Vue.js application is rendered in the browser" only, and the page is built from scratch after the browser downloads and parses the JavaScript - the same total-failure shape as a plain unframeworked SPA. The practical check for any Nuxt page: verify the composable, its server option, and whether any applicable routeRules entry disables SSR for that route - not just which function name appears in the component.
The Failure Mode That Survives a Framework Migration
Put together, the pattern across both frameworks is the same: the framework's default behavior is not the problem. The problem is any individual piece of content - a pricing table, a feature comparison, a testimonial block - that was written to fetch or generate its data using a client-only mechanism instead of the framework's server-aware one. This is exactly why a site can migrate from a plain SPA to Next.js or Nuxt, still show up as "fixed" in a casual glance at view-source, and still be functionally blank to GPTBot on the specific page that matters most. The fix isn't only "adopt an SSR framework" - it's auditing which components on your highest-priority pages are still fetching their content the client-only way after that migration.
Third-party embeds carry the same risk independent of your own framework choice: A/B testing tools, personalization engines, and chat-widget-style content injectors that write their own markup into the page via client-side JavaScript produce content that was never in the server response either - regardless of what framework the surrounding page uses. If a testimonial carousel or a personalized pricing block is coming from a third-party script tag, check whether that specific block is actually present in the raw HTML the same way you'd check any other component.
How to Check Which Failure Mode You Have
Be precise about what a log-based check can and can't prove. Standard access logs - NGINX's default format, Cloudflare's request logs, most others - record request metadata: path, status code, timing, bytes sent, the claimed user agent. They don't store the response body or anything about what a crawler actually parsed out of it. A log entry showing "GPTBot requested /pricing, got a 200, N bytes" confirms a request happened and something was returned - it doesn't confirm the returned HTML contained your pricing content, and a user-agent string claiming to be GPTBot is unverified until its source IP is checked against OpenAI's own published ranges (see the user-agent reference page for each operator's verification method).
Comparing response sizes across a "content-complete" page and one you suspect is affected is a useful way to prioritize which pages to look at more closely - a suspiciously small response is worth checking first. It's not a pass/fail test on its own: two pages can return similar byte counts with very different content, and size differences alone can come from compression, response headers, personalization, caching, or an error template rather than a genuine content gap. The actual test is inspecting the response body: fetch the URL the way a non-rendering crawler would (no browser, no JavaScript execution) and check whether the specific text or DOM block you care about is present in that raw HTML - then compare it against what the browser-rendered DOM shows a human visitor. That comparison is what actually tells you whether you're looking at a framework-wide SPA problem or one component's client-only fetch, not the byte count by itself.
Run the self-test on your own site →
What to Do Next
← Back to the full pillar page
Fixing it: SSR vs. prerendering decision guide →
The full implementation checklist →
Sources: App Router Server/Client Component and prerendering behavior, and the "attaching event handlers to the DOM" hydration framing, quoted directly from Next.js's official documentation (a maintained, regularly-updated page - checked again as of this revision rather than pinned to one dated snapshot). The "initial HTML snapshot" hydration framing and the hydrateRoot/createRoot distinction from React's official hydrateRoot reference. Create React App's February 2025 deprecation from React's own blog. Nuxt's default universal-rendering behavior, SPA-mode (ssr: false) behavior, and per-route routeRules from Nuxt's official v4 rendering-modes documentation. The useFetch/useAsyncData server-to-client payload behavior and the server: false client-only-fetch option from Nuxt's official data-fetching documentation. onMounted's explicit SSR exclusion from Vue's official lifecycle-hooks documentation. Non-execution behavior for GPTBot, ClaudeBot, and PerplexityBot specifically (December 2024, dated and agent-scoped, not a permanent guarantee) from Vercel's crawler study; Googlebot's separate crawl-render-index pipeline from Google's JavaScript SEO documentation. What standard access logs do and don't record from NGINX's access-log module documentation and Cloudflare's log-request documentation - both document request/response metadata, not response body content. Josh W. Comeau's "Making Sense of React Server Components" and Builder.io's "5 Misconceptions about React Server Components" are kept as optional explanatory reading on the Client Component/prerendering point, not as sources carrying equal authority to the framework documentation above. Framework documentation changes with new releases - recheck against current docs before treating a specific version's behavior as settled.
About the author
Zarko Zivkovic is the founder of CoreAEX, building technical SEO, AEO, and AI-visibility systems for B2B SaaS companies. Connect on LinkedIn.