Agent Readiness in React and Next.js
React sites can look perfect in a browser and still hand AI crawlers an almost blank page. The cause is where the HTML gets built: if React builds it in the visitor's browser, a crawler that does not run scripts receives only the empty container.
Even Next.js, which renders on the server by default, produces empty sections when data is fetched in browser-only code. This guide covers the React-specific causes, from client-only templates and useEffect data loading to hash routes and click-handler links, and the code changes that make every public page arrive complete.
Why React sites are often invisible to AI crawlers
A classic React single-page app sends the same tiny HTML file for every URL: a head, an empty <div id="root"></div>
and a script bundle. The browser downloads the bundle, React starts, works out which page to show from
the address, fetches the data and builds the page. Everything a visitor reads is created in those last
steps.
Google renders JavaScript in a second pass, so client-rendered React pages can still appear in Google Search. Most AI crawlers do not do this. They read the HTML from the first response and extract text from it. For a client-rendered app, that text is often just the page title and a "You need to enable JavaScript" message.
Server rendering frameworks fix this in principle, because the server runs your components and sends real HTML. In practice, several React habits undo it:
- Fetching content inside
useEffect, which only runs in the browser, so the server-rendered HTML shows a loading state. - Loading components with server rendering switched off, which leaves an empty space in the HTML.
- Reading from
windoworlocalStorageto decide what to show, which forces content to wait for the browser. - Navigating with click handlers instead of links, so crawlers cannot discover other pages.
What a crawler receives from each setup
| Setup | HTML a crawler receives | Usual fix |
|---|---|---|
| Create React App, or Vite with the plain React template | An empty root div and scripts | Add build-time pre-rendering for public routes, or move to a framework |
| Next.js App Router, server components fetching data | Complete HTML | Nothing, as long as content is not moved into browser-only code |
Next.js with data fetched in useEffect or a client-side data library | The layout and a loading state | Fetch in a server component, or pass server-fetched data in as props |
Next.js Pages Router with getStaticProps or getServerSideProps | Complete HTML | Nothing; check no page relies on client-only fetching instead |
| React Router in framework mode, or Remix | Complete HTML when loaders fetch the data | Move fetching into loaders; pre-render static routes |
| Gatsby, or Astro with React components | Complete HTML built at deploy time | Check components marked to render only in the browser |
The label on the box matters less than what arrives. Test the actual HTML response for each template, as described below, before deciding what to change.
Next.js specifics that trip people up
The use client boundary
In the App Router, components are server components unless a file starts with 'use client'.
Client components are still rendered to HTML on the server for the first response, so marking a component
as a client component does not hide its markup by itself. What hides content is fetching the data after
the component mounts. This pattern leaves crawlers with the loading message:
'use client'
export default function Reviews({ productId }) {
const [reviews, setReviews] = useState(null)
useEffect(() => {
fetch(`/api/reviews/${productId}`).then(r => r.json()).then(setReviews)
}, [productId])
if (!reviews) return <p>Loading reviews...</p>
return <ReviewList items={reviews} />
}
The same content fetched in a server component arrives complete, and any interactive part can still be a small client component inside it:
export default async function Reviews({ productId }) {
const reviews = await getReviews(productId)
return <ReviewList items={reviews} />
}
Components loaded without server rendering
dynamic(() => import('./Specs'), { ssr: false }) tells Next.js to leave that component
out of the server HTML. It is meant for things that genuinely cannot run on a server, such as a map library
that needs window. Search your codebase for ssr: false and check none of those
components hold text that matters.
Streaming and loading states
With loading.tsx or Suspense boundaries, Next.js sends the page frame first and streams
slower parts later in the same response. The content is still in the HTML, but it comes at the end of
the document. Keep the core text outside slow Suspense boundaries so it arrives in the first part of
the response, and keep slow calls such as recommendations inside them.
Metadata and structured data
Use the Metadata API for titles, descriptions, canonical URLs and social tags, so they are part of
the server response. Set metadataBase in the root layout so relative canonical paths become
full URLs. JSON-LD is not part of the Metadata API; render it as a script tag in the page component,
escaping the less-than character as the Next.js documentation recommends:
export default async function Page({ params }) {
const { slug } = await params
const guide = await getGuide(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: guide.title,
dateModified: guide.updatedAt,
}
return (
<article>
<script type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd).replace(/</g, '\\u003c') }} />
<h1>{guide.title}</h1>
...
</article>
)
}
Next.js also supports app/robots.ts and app/sitemap.ts, which generate those
files at build or request time. If you use them, check the output at /robots.txt after each
deploy, because a rule written for a staging environment can easily reach production.
Routing, links and status codes in single-page apps
Even with rendering fixed, single-page apps have three routing habits that confuse crawlers.
- Hash routes. Addresses such as
example.co.uk/#/pricingare one URL to a crawler, because everything after the#is never sent to the server. Every "page" collapses into the home page. Switch to path-based routes such as/pricing, with the server configured to handle them. - Click-handler navigation. A
<div onClick={() => navigate('/pricing')}>works for a mouse but gives a crawler nothing to follow. Use your router's link component, such asnext/linkor React Router'sLink, which render a normal<a href>. - Everything returns 200. A client-only app answers every address, including ones
that do not exist, with the same HTML and status 200, then shows "Page not found" by script. Crawlers
store those as real pages. In Next.js, call
notFound()when data is missing so the response is a 404; in a pre-rendered SPA, configure the host to serve a real 404 for unknown paths.
If you pre-render a Vite or similar app at build time rather than migrating, make sure every public route is in the pre-render list, including product and article pages generated from data. Routes left out fall back to the empty shell.
The fix, step by step
Step 1
Find out which rendering mode each route uses
List your templates: home, listing, detail, article, landing pages. For each one, fetch a live URL with curl using a crawler user agent, save the HTML and search it for a sentence from the main content. In Next.js, the build output also marks each route as static or dynamic. Record which routes arrive complete, which arrive as a layout with loading states and which arrive as an empty root element.
Step 2
Move data fetching to the server
For
every piece of public content fetched after mount, move the fetch to where the framework runs it on the
server: an async server component in the Next.js App Router, getStaticProps or getServerSideProps
in the Pages Router, or a route loader in React Router framework mode. Prefer static generation with
timed revalidation for content that changes daily or less, so the finished HTML can be cached.
Step 3
Keep client components for interaction only
Go through files marked 'use client' and components loaded with ssr: false.
Where one holds content as well as interaction, split it: render the text, prices and specifications
in a server component and wrap only the interactive control, such as a quantity picker or filter panel,
in a small client component. Remove any rendering that depends on window or localStorage
for public content.
Step 4
Give every page a real URL and a real link
Replace hash-based routes with path-based ones and make sure the host serves each
path directly. Search the code for click handlers that call navigate or router.push
on elements that are not links, and replace them with the router's link component so each renders an
anchor with an href. Tabs or filters that show different content should each have their own address if
that content should be found.
Step 5
Output metadata and structured data from the server
Move titles, descriptions, canonical URLs and social tags out of browser-side
head managers and into the Metadata API or your framework's equivalent, so they appear in the first response.
Set metadataBase once in the root layout. Render JSON-LD as a script tag inside the server-rendered
page, built from the same data as the visible content, so the two cannot drift apart after a content
edit.
Step 6
Return proper status codes
Request
a URL that does not exist and check the status code with curl -I. If it returns 200, fix
it: call notFound() in Next.js when a record is missing, throw a 404 response from a React
Router loader, or configure your static host to serve a 404 page with a 404 status. Use real server redirects
with a 301 or 308 status for moved pages rather than redirecting in the browser.
Step 7
Build it into your tests
Add an automated test that fetches a sample URL for each template from a production build, without running scripts, and fails if a known phrase, the h1 or the JSON-LD block is missing. Run it in your deployment pipeline so a future change that moves content into the browser is caught before release. Then run the Agent Readiness Audit on each template and keep the results for comparison.
The rest of the audit on a React site
Rendering is the React-specific part. The other findings are fixed the same way on any stack, and each has its own guide:
- Crawler access. Hosting platforms and CDNs in front of React apps often have bot protection or AI-crawler blocking settings. See fixing AI crawler robots.txt blocks.
- Structure. Component-heavy code tends towards deep div nesting. Fixing non-semantic HTML covers landmarks and headings, including passing heading levels into reusable components.
- Extractability. Content inside tabs, accordions and carousels that only load on interaction; see fixing poor content extractability.
- Structured data and URLs. JSON-LD coverage and canonical conflicts, which in React apps often come from trailing-slash settings and query-string variants.
- Authorship and llms.txt. Author and trust signals and the optional llms.txt file, which in Next.js can be a static file in the public folder or a route handler.
Speed matters too once the HTML is complete: a server-rendered page with slow data calls and no caching can still be too slow for a crawler. See fixing slow rendering for AI crawlers.
Keeping it right as the app grows
- Agree a team rule: public content is fetched on the server; browser-side fetching is for personal or interactive data only.
- Flag new uses of
ssr: falseand new'use client'files in code review and ask what they render. - Keep the raw-HTML test from step 7 in the pipeline, and extend it when you add a template.
- After framework upgrades, re-check route rendering modes, because defaults for caching and static generation have changed between major Next.js versions.
- Re-run the Agent Readiness Audit after hosting, CDN or framework changes.
1. Google indexes my React app, so why would AI crawlers not see it?
Google runs a rendering service that executes JavaScript, so it can see pages that React builds in the browser, although sometimes with a delay. Most AI crawlers do not render scripts; they take the HTML from the first response and extract text from that. A client-rendered app therefore works for Google and fails for them. Check the raw HTML with curl or with JavaScript disabled, because Google tools will not show you this difference.
2. Does marking a component use client hide it from crawlers?
Not by itself. In the Next.js App Router, client components are still rendered to HTML on the server for the first request, so their initial markup is in the response. The problem comes from what they do after that: fetching data in useEffect, reading browser storage or waiting for user interaction before showing content. If the text depends on any of those, the server HTML contains the loading state instead of the content, and that is what the crawler keeps.
3. Do I need to move from a Vite single-page app to Next.js?
Not necessarily. If only a handful of routes are public and their content changes rarely, pre-rendering those routes at build time can be enough, and several tools and plugins do this for Vite projects. If you have many data-driven public pages such as products or articles, a framework with server rendering or static generation, whether Next.js, React Router in framework mode or Astro, is usually less work to maintain than keeping a pre-render list up to date.
4. Is a pre-rendering service a good fix for a React app?
It can be a practical stopgap for an older app that cannot be rebuilt soon. The service runs your pages in a headless browser, stores the resulting HTML and serves it to crawlers. The drawbacks are cost, snapshots that go out of date, and the risk of bots and people seeing different content, which search engines treat as cloaking. Google describes serving bots a separate rendered version as a workaround, so plan a proper rendering change as well.
5. How do I add JSON-LD in the Next.js App Router?
Build the structured data object in the page component from the same data used for the visible content, then render it as a script tag with type application/ld+json and the object serialised with JSON.stringify. The Next.js documentation recommends escaping the less-than character in that string to avoid injection problems. Put site-wide types such as Organization in the root layout and page-specific types such as Article or Product in the page itself.
6. Why does my Next.js site show a loading spinner in the raw HTML?
Either the content is fetched in browser-only code, or it sits inside a Suspense boundary or loading file and is streamed later in the response. In the first case, move the fetch into a server component or a server data function. In the second, the text is still in the response but near the end, after the spinner markup. Keep the core content outside slow Suspense boundaries so it arrives in the first part of the document.
7. Can AI crawlers follow links in a React app?
Only if they are real links. Crawlers look for anchor elements with an href attribute and request those addresses. Router link components in Next.js and React Router render proper anchors, so they work. Buttons, divs or spans with click handlers that change the route do not, and neither do hash-based addresses, which all look like the same page. If a crawler cannot discover a page through links or your sitemap, it will rarely fetch it at all.
The Agent Readiness Audit requests your page with real AI crawler user agents and tests how much of the content depends on JavaScript, alongside robots.txt, llms.txt, JSON-LD, semantic HTML and content extractability checks.
Open Agent Readiness Fixes โ