Why React sites fail quietly
Most technical SEO problems on a Next.js site are not exotic. A canonical points at the wrong URL, a link only exists after a click, a sitemap lies about dates. The framework gives you good defaults, but it also lets you ship each of these defects without a build error or a failing test.
This guide is written for the person who has to find the cause: an in-house dev lead or a founder whose site is not indexing, or is indexing the wrong pages. Where we give examples, they come from our own site, which runs on Next.js 14 with the App Router. We audited it, found these problems, and fixed them. We make no claims about traffic or rankings here, only about what was wrong and how we checked.
Start with what a crawler receives, not what a browser shows. Fetch the page with `curl`, read the raw HTML, and compare it to the rendered page. Most of the sections below are variations on that one comparison.
Rendering modes and what crawlers see
Next.js can produce a page in several ways, and each one changes what is in the first HTML response. Search engines can render JavaScript, but rendering is a separate, later step and not a guarantee. Content and links that are already in the initial HTML are the safest to rely on.
Rendering modes and crawling
| Mode | How it works in the App Router | What the crawler gets |
|---|---|---|
| Static (SSG) | Built ahead of time; dynamic routes use `generateStaticParams` | Complete HTML in the first response |
| SSR | Rendered on the server per request | Complete HTML, produced at request time |
| ISR | Static page regenerated after a `revalidate` interval | Complete HTML, possibly slightly stale |
| Client-only | Component marked `'use client'` that fetches or mounts content in the browser | An empty shell until JavaScript runs |
A `'use client'` component is still server-rendered on first load in the App Router. The risk is content that is fetched or mounted only after hydration, not the directive itself.
For pages that exist to be found, such as service pages, guides and location pages, static or server rendering is the default to choose. Reserve client-only rendering for interactive tools behind a login, where indexing does not matter.
App Router metadata: canonicals and hreflang
The App Router exposes metadata through the `metadata` object or `generateMetadata`. Two parts of it cause most of the damage when they are wrong: `alternates.canonical` and `alternates.languages`.
A layout-level canonical cascades to every child
Metadata merges down the route tree. If a layout sets `alternates.canonical`, every child page that does not set its own inherits that value, and so declares the layout's URL as its canonical. On our site, a root-layout canonical meant pages without their own value all pointed at the same address. The fix is a rule: every page that should be indexed sets its own canonical in its own `generateMetadata`, and layouts do not set one.
Hreflang alternates must be reciprocal
If the English page lists a Spanish alternate, the Spanish page must list the English one back, and each page should reference itself. A one-way declaration is ignored. When we audited ours, we found pages with hreflang pointing at themselves in every language slot, mixed code styles (`en` alongside `en-US`), and sections with no hreflang at all. We settled on one policy: `en-US`, `es-US` and `x-default`, emitted only when a real translated counterpart exists.
No counterpart, no hreflang
Do not declare an alternate for a page that has no real translation. A lone self-reference adds nothing, and an alternate that points at a redirect or 404 is worse. Check reciprocity by fetching the counterpart page and reading its tags, not by trusting the config.
When the checklist finds more than you can fix
Everything above can be checked in an afternoon with `curl`, a browser and Search Console. If the audit turns up structural problems across templates, technical SEO engineering is the part of our work that addresses them at the source.
Bring us the audit findings
Send what you found, whether it is a canonical cascade, hidden links or a bloated sitemap. We will tell you which are template-level fixes and which are not worth your time.
sitemap.ts and honest lastmod
In the App Router, `app/sitemap.ts` returns an array of URL entries, each with an optional `lastModified`. It is tempting to write `const now = new Date()` at the top and stamp it on everything. Ours did exactly that: a build-time date on most URLs, which told crawlers on every deploy that every page had just changed.
A build timestamp is not a modification date. Once a sitemap's dates are visibly unreliable, there is no reason for a crawler to trust them. Our policy now is that a URL carries `lastModified` only when a real, maintained date exists for it, such as a guide's editorial `dateModified`. Otherwise the field is omitted. The sitemap protocol treats `lastmod` as optional, so omission is always better than a fabricated value.
- List only canonical, indexable, 200-status URLs. Redirects, noindexed pages and 404s do not belong.
- Source dates from content (a CMS field or a data file), never from the build clock.
- If a page has no maintained date, leave `lastModified` out.
- Reference the sitemap from `app/robots.ts` so crawlers find it without Search Console.
Duplicate structured data from shared layouts
JSON-LD emitted from a shared layout is convenient, and it is a frequent source of duplication. A layout outputs an `Organization` or `LocalBusiness` node, then a page template outputs another one, often with slightly different properties. The result is two entities describing the same business, which can disagree.
We measured this on our own site and found it on 93 of 140 URLs, from two causes. Some templates re-declared the provider with a type and properties instead of referencing it. A few pages declared a provider with no `@id`, creating a separate organization that competed with the real one. The fix was a single helper that returns `{ "@id": ORG_ID }`: the organization is described once, in one place, and every other node points to it. The count went to zero.
- Describe each entity once, with a stable `@id`, and reference it by `@id` everywhere else.
- Choose the schema type that matches reality. A business with no public premises should not claim a `LocalBusiness` address.
- Do not mark up content that users cannot see. FAQ markup should match visible questions and answers.
- Validate the rendered output of real pages, not just the component that builds it.
404s for unknown slugs and redirects for retired URLs
Dynamic and catch-all routes can turn every URL into a 200. If a route handler receives a slug it does not recognize and renders a page anyway, that is a soft 404: the content says "not found", the status says "fine". Call `notFound()` from `next/navigation` for unknown slugs, and the framework serves your not-found page with a 404 status.
There is a related trap with `generateStaticParams`. Known slugs get prerendered, but unknown ones still reach the server unless you restrict them. On our site we left `dynamicParams` at its default and had the route call `notFound()` itself. Setting `dynamicParams = false` made Next reject the request before the segment rendered, and the unstyled built-in 404 came back with none of our layout or tracking. Test by requesting a nonsense URL and reading the status and the HTML.
Retired URLs are a different case. If a page moved, add a permanent redirect in `next.config.js` so the old address passes to the new one. If it is gone for good, return a 404 or 410. Never redirect everything to the homepage.
A diagnosis checklist you can run today
- 1Fetch raw HTML. Run `curl -s URL` for five representative templates. Confirm the title, canonical, main copy and key links are present without JavaScript.
- 2Grep for important hrefs. Search the raw HTML for the URLs you most need crawled. Zero matches means a hidden or client-only link.
- 3Read each canonical. Every indexable page should have a self-referencing canonical, set by the page and not inherited from a layout.
- 4Check hreflang pairs. For each alternate, fetch the counterpart and confirm it points back.
- 5Audit the sitemap. Confirm it lists only 200-status, indexable URLs, and that `lastModified` values are real or absent.
- 6Inspect JSON-LD. Count entities per page. One organization node, referenced by `@id`, is the goal.
- 7Probe status codes. Request a made-up slug at each route level and confirm a true 404. Request retired URLs and confirm a single redirect hop.
- 8Compare against Search Console. Check the Page indexing report for patterns that match the defects above.
Common mistakes in Next.js SEO audits
Trusting the browser view
The rendered page shows content the first HTML response may not contain. Always check the raw response.
Fixing one page, not the template
A canonical or schema defect usually lives in a shared layout or component. Patch it there and re-test several routes.
Treating the sitemap as a fix
A sitemap helps discovery. It does not replace internal links, and it cannot repair a wrong canonical.
If your site was built by a team new to the framework, our web development and Next.js development pages describe how we build for crawlability from the start. The broader SEO engineering line and technical SEO cover audits and fixes, and the guides library has related reading.
Frequently asked questions
Is Next.js good for SEO?
Next.js gives you server rendering, static generation and a metadata API, which make it possible to ship complete HTML and correct tags. It does not make a site rank on its own. The defects in this guide, such as inherited canonicals, hidden links and duplicate schema, appear on Next.js sites regardless of the framework's defaults.
Can Google index client-side rendered React content?
Google can render JavaScript, but rendering happens as a separate step after the initial crawl and is not guaranteed for every page. Content and links already present in the first HTML response are the most reliable. For pages meant to be found, prefer static or server rendering.
Do I need a canonical tag on every page?
Every indexable page should have a canonical URL that points to itself unless a different page is the real source. In the App Router, set it per page with `alternates.canonical` in `generateMetadata`. Avoid setting it in a layout, because children inherit it.
What should go in lastmod in a Next.js sitemap?
Use a real content modification date, such as an editorial updated date stored with the page. If you do not have one, omit the field. A build timestamp applied to every URL does not describe when anything changed.
How do I return a real 404 in the App Router?
Call `notFound()` from `next/navigation` inside the page or `generateMetadata` when a slug does not match known content. Then request a nonsense URL and confirm the response status is 404, not 200.
Where do I start if my React site is not indexing?
Fetch the raw HTML of a non-indexed page with `curl` and look for the main content, the canonical and incoming internal links. Then check the sitemap and Search Console's Page indexing report. Most cases trace back to one of the defects described above.