Generic SEO audits mostly pass React sites while missing what is actually wrong with them. The crawler-based tools check titles, links, and status codes on whatever HTML they receive — but on a framework site, the interesting failures live in the gap between the server response and the hydrated application: links that are not links, titles set after the fact, 404 pages that return 200, and content that only exists after a client-side fetch.
These failures cluster in four zones. Audit those four deliberately and you will find most of what matters; audit generically and you can miss all of them.
Routing: are your links actually links
Client-side routers intercept navigation, and that creates the first failure class: navigation that works for users but does not exist for crawlers.
- Links built as click handlers. A
<div onClick={...}>navigates fine in the browser, but Google has documented that it discovers links from<a>elements withhrefattributes. Anything else is invisible for discovery. Framework link components (next/link, React Router'sLink) render real anchors — the failures come from custom components and hand-rolled handlers. Audit by inspecting rendered HTML for the actual anchor elements, not by clicking around. - Hash and memory routing. Routes served under
#/pathare fragments, not URLs; Google has long treated the fragment as client-side state. Anything but real path-based routing takes those pages out of the index conversation entirely. - Route existence without server knowledge. In SPA setups the server serves one shell for every path and the router decides what exists. This is the root cause of the status-code zone below, and it also means URL-level things — redirects, per-route caching — have no server-side anchor unless the framework provides one. Next.js's file-based routing largely solves this, which is one of the honest arguments for it.
Head management: who sets the title, and when
Every framework site has some mechanism writing <title>, meta description, canonical, and robots meta. The audit question is where that mechanism runs.
- Server versus client. Metadata rendered into the initial HTML (Next.js App Router's metadata API, or any SSR head manager) is unambiguous. Metadata injected client-side — classic
react-helmetin a CSP-rendered app,useEffectcalls writingdocument.title— depends on rendering, and Google's documented two-phase indexing means the pre-render HTML is what exists first. Curl a few route types and check whether the title, canonical, and robots meta in the raw response are correct, not just present. - Shell metadata leaking. A common shape: every route's raw HTML carries the homepage title and description from the app shell, corrected later by JavaScript. Rendered-HTML crawls look fine; the server response is a sitewide duplicate.
- Canonicals on parameterized routes. Client-side state often lives in query parameters the router happily accepts. Every route needs a canonical rendered server-side, and it needs to be per-URL — a canonical hardcoded in the shell pointing at the origin is a sitewide self-inflicted wound.
- Conflicting robots meta. SSR emits one robots meta, a client component emits another. Check for doubled tags in the hydrated DOM; behavior with conflicting directives is not something to leave to inference.
Status codes: does anything ever 404
HTTP status is decided before JavaScript runs, so a client router cannot set one. The audit here is blunt and effective: request nonsense URLs and watch the wire.
- Soft 404s.
curl -i https://example.com/definitely-not-a-page— if the shell comes back200and the router renders a "not found" component, that is a soft 404. Google has documented that it tries to detect these, but detection is heuristic; the reliable fix is a real 404/410 from the server, which SSR frameworks support and pure SPAs structurally cannot. - Client-side redirects.
window.locationor router-level redirects return 200 plus a JavaScript hop instead of a 301. Google can process JavaScript redirects, but they are slower to consolidate and invisible to non-rendering crawlers. Migrations and URL changes belong in server or edge config (next.configredirects, or the hosting layer). - Error states returning 200. A failed data fetch that renders an error boundary under a 200 tells crawlers the error page is the content. If the data layer fails during SSR, the response status should say so.
Hydration and rendering: what exists before JavaScript
The core question: how much of the page is in the server HTML, and how much appears only after hydration and client-side fetching?
- Content behind
useEffect. Data fetched after mount does not exist in the initial HTML. Google renders JavaScript, but rendering is deferred and adds failure modes — as covered in our Googlebot rendering post — so the audit point is knowing which content depends on it. Diffcurloutput against the rendered DOM for each template; the diff is your rendering dependency, and anything critical in it is a candidate for moving to server-side data fetching. - Hydration errors. When server HTML and client render disagree, React can discard the server HTML and re-render — which forfeits the benefit of having rendered on the server. The framework logs these warnings in development; treat them as SEO bugs, not cosmetic ones.
- Personalization and consent gates. Components that render nothing until they know the viewer (locale prompts, consent-gated content) frequently render nothing for crawlers, permanently. Default content should exist server-side.
What to do
Run this sequence per template type, not per page — framework failures are template-level:
- Curl the raw HTML for each route type and confirm: correct title, description, canonical, robots meta, and the primary content, all present without JavaScript.
- Curl a garbage URL and a deleted-content URL and confirm real 404/410 status codes on the wire.
- Grep the rendered DOM for anchors — every navigation path a crawler should follow exists as
<a href>. - Diff raw versus rendered HTML and list what only exists post-hydration; move anything indexing-critical into the server render.
- Verify redirects return 301/308 via curl, not a 200 with a client hop.
- Fix hydration warnings in development builds before they become silent re-renders in production.
None of this needs enterprise tooling — curl, view-source, and the framework's own dev warnings cover most of it. What it needs is auditing the server response and the hydrated app as two separate artifacts, because on a framework site, they are.