React SEO: Common Rendering and Indexing Problems in React Applications
- In a React 18 + Vite single page application we audited, each landing page set its own title, meta description and canonical link inside a
useEffecthook. Crawlers that read the HTML before executing JavaScript saw the home page values on all six routes. - The rendered DOM was correct, so the problem was invisible in a browser and in most manual checks. It only surfaced in social preview debuggers and in Search Console, where the generic title persisted.
- The fix that shipped was a post-build script that writes one static
index.htmlper route with the correct tags, plus one server rewrite per route. Prerendering and SSR remain options but were not needed for six pages.
The symptom: correct in the browser, wrong everywhere else
The site was a React 18 application built with Vite. It had a small number of path-based landing pages, six in total, all served by the same index.html. Routing was not handled by React Router in this case. App.jsx read window.location.pathname and picked a component from a map:
const routes = {
"/": Home,
"/pricing": Pricing,
"/integrations": Integrations,
"/security": Security,
"/about": About,
"/contact": Contact,
};
const Page = routes[window.location.pathname] ?? NotFound;
Each page component set its own metadata inside an effect, and restored the previous values on unmount:
useEffect(() => {
const prevTitle = document.title;
const prevDesc = document
.querySelector('meta[name="description"]')
?.getAttribute("content");
const prevCanonical = document
.querySelector('link[rel="canonical"]')
?.getAttribute("href");
document.title = "Pricing | Example";
document
.querySelector('meta[name="description"]')
?.setAttribute("content", "Plans and limits for the Example platform.");
document
.querySelector('link[rel="canonical"]')
?.setAttribute("href", "https://example.com/pricing");
return () => {
document.title = prevTitle;
document
.querySelector('meta[name="description"]')
?.setAttribute("content", prevDesc ?? "");
document
.querySelector('link[rel="canonical"]')
?.setAttribute("href", prevCanonical ?? "");
};
}, []);
In a browser this behaves exactly as intended. Navigate to /pricing, the tab title changes, the description updates, the canonical points at the pricing URL. The developer who wrote it had no reason to suspect anything.
The failure is in the ordering. The server returns one HTML document for every path. That document contains the home page <title>, the home page meta description, and a canonical link pointing at the home URL. The React bundle then loads, executes, and rewrites those tags. Any consumer that reads the initial HTML and does not execute the JavaScript sees the home page values for all six routes.
The three places this is noticed, in the order people usually notice them:
- Sharing a link on a social platform or in a chat app produces a preview for the home page, with the home title, home description and home image, regardless of which page was shared. This is the loudest signal because it is visible to anyone, immediately.
- Search Console reports the generic title for the affected URLs. The report shows the title that was indexed, and it does not match what the developer sees in the browser. This is confusing until you understand which copy of the page is being read.
- View source shows the home metadata. This is the simplest check and the one most often skipped, because developers test in the browser and the browser runs the effect.
How to diagnose it
The diagnosis is a comparison between two versions of the same URL: the raw HTML and the rendered DOM. Everything else follows from that.
Fetch the raw HTML. Do not use a browser. Use curl or any HTTP client that does not execute JavaScript:
curl -s https://example.com/pricing | grep -E '<title>|canonical|description|og:'
If the output shows the home title, the home description and a canonical pointing at https://example.com/, the page is not server-rendered in any form and the tags are being injected at runtime. Repeat for each route. In this case all six returned the home values.
Compare with the rendered DOM. Run the same URL through a headless browser and dump the head after the page settles:
npx playwright open https://example.com/pricing
# or, scripted:
node -e "
const { chromium } = require('playwright');
(async () => {
const b = await chromium.launch();
const p = await b.newPage();
await p.goto('https://example.com/pricing', { waitUntil: 'networkidle' });
console.log(await p.title());
console.log(await p.\$eval('link[rel=canonical]', el => el.href));
await b.close();
})();
"
If the rendered values are correct and the raw values are not, the problem is confirmed and there is nothing else to investigate. The tags exist only in the DOM, never in the response body.
Check what the crawler actually got. In Search Console, use the URL Inspection tool on one affected URL and open the "View crawled page" panel. It shows the HTML Google received. If it shows the home title, the same conclusion applies. The "Test live URL" button will show the rendered version, which is why the two views can disagree and why the crawled copy is the one that matters.
Check social previews directly. Use the debugger tools each platform provides for the Open Graph tags. They fetch the URL without executing JavaScript in most cases. A preview showing the home title on a subpage is the same defect, seen from a different consumer.
Check the sitemap against the HTML. List the URLs in the sitemap and run the curl check over all of them. If every URL returns the same title, the problem is site-wide for that route group, not a one-off. This is the fastest way to size it.
What the data showed
The audit produced a small, unambiguous set of observations.
- Before rendering, every one of the six landing pages returned the home
<title>, the home meta description, and a canonical link pointing at the home URL. This was consistent across all six, not intermittent. - After rendering, all six returned the correct title, description and canonical for their own route.
- Social previews were wrong for all six pages. Every shared link produced the home page preview.
- Search Console showed the generic title for the affected URLs until rendering. The indexed title did not match the page.
- The 4,314 static pages on the same domain were indexed without any of this. Those pages are plain HTML, generated at build time, and their metadata is present in the response body. They never had the problem, which is the strongest evidence that the issue is specific to runtime injection and not to anything about the domain, the hosting or the crawler.
The contrast between the six dynamic routes and the 4,314 static pages is the whole story. Same domain, same server, same crawler. The only difference is whether the metadata exists in the HTML or is added by JavaScript after load.
| Check | Before rendering | After rendering | Static pages on same domain |
|---|---|---|---|
<title> |
Home title on all 6 routes | Correct per route | Correct in HTML |
| Meta description | Home description on all 6 routes | Correct per route | Correct in HTML |
| Canonical link | Points at home URL on all 6 routes | Correct per route | Correct in HTML |
| Open Graph tags | Home values, so previews wrong for all 6 | Correct per route | Correct in HTML |
| Search Console indexed title | Generic title | Not applicable | Correct title |
| Indexing outcome | 4,314 static pages indexed normally | Dynamic routes affected | 4,314 static pages indexed normally |
The fix
Three options were on the table, in order of effort. The first was enough for six pages.
Option 1: a post-build script that emits one HTML file per route. After vite build produces dist/index.html, a Node script reads it, and for each route writes dist/<route>/index.html with the title, description, canonical and Open Graph tags replaced. The server then needs one rewrite per route so that /pricing serves dist/pricing/index.html instead of dist/index.html. The React app continues to work unchanged, because it still boots and renders the same components. The difference is that the response body now contains the correct tags before any JavaScript runs.
// scripts/static-meta.mjs
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";
const routes = [
{ path: "/pricing", title: "Pricing | Example",
desc: "Plans and limits for the Example platform." },
{ path: "/integrations", title: "Integrations | Example",
desc: "Connect Example to the tools you already use." },
// ...one entry per route
];
const base = readFileSync("dist/index.html", "utf8");
for (const r of routes) {
let html = base
.replace(/<title>.*?<\/title>/, `<title>${r.title}</title>`)
.replace(
/<meta name="description" content="[^"]*"/,
`<meta name="description" content="${r.desc}"`
)
.replace(
/<link rel="canonical" href="[^"]*"/,
`<link rel="canonical" href="https://example.com${r.path}"`
)
.replace(
/<meta property="og:title" content="[^"]*"/,
`<meta property="og:title" content="${r.title}"`
)
.replace(
/<meta property="og:url" content="[^"]*"/,
`<meta property="og:url" content="https://example.com${r.path}"`
);
const out = `dist${r.path}/index.html`;
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, html);
}
Wire it into the build so it cannot be forgotten:
{
"scripts": {
"build": "vite build && node scripts/static-meta.mjs"
}
}
The server rewrite, for nginx:
location = /pricing { try_files /pricing/index.html /index.html; }
location = /integrations { try_files /integrations/index.html /index.html; }
location = /security { try_files /security/index.html /index.html; }
location = /about { try_files /about/index.html /index.html; }
location = /contact { try_files /contact/index.html /index.html; }
The tradeoff is that the route list now exists in two places: the app and the build script. That is a real maintenance cost. For six pages it is small. For sixty it is worth generating both from one source file. The useEffect blocks can stay, since they keep client-side navigation correct, but they are no longer load-bearing for crawlers.
Option 2: prerendering. A prerender step runs the app in a headless browser at build time and writes the resulting HTML for each route. This removes the manual tag replacement and the duplicated route list, and it captures whatever the app renders rather than a hand-written subset of tags. The cost is a headless browser in the build, longer build times, and a new class of failure when a page depends on data that is not available at build time. For a site with content that changes often, this becomes a pipeline to maintain rather than a script.
Option 3: server-side rendering. The server renders the React tree to HTML per request and the client hydrates it. This is the most correct answer for pages whose metadata depends on request-time data, and it is the only option that scales cleanly to thousands of dynamic routes. It is also the largest change: a Node server in the request path, hydration mismatches to debug, and a different deployment model. For six landing pages whose content is static, it was not justified.
The choice depends on how many routes there are and whether their metadata is known at build time. If it is known at build time and the count is small, option 1 is a script. If the count is large or the tags are complex, option 2. If the metadata depends on the request, option 3.
How to verify the fix
Verification is the same comparison that found the problem, run again after deploy.
Before: curl -s https://example.com/pricing | grep -E '<title>|canonical' returned the home title and a canonical pointing at the home URL. The same held for all six routes.
After: the same command returns the pricing title and a canonical pointing at https://example.com/pricing. Run it for every route in the sitemap, not just one. A partial deploy that fixes four of six routes is a common failure mode.
What to measure:
- The number of routes whose raw HTML contains the correct title. Before: zero of six. After: six of six.
- The number of routes whose raw HTML canonical points at their own URL. Before: zero of six. After: six of six.
- Social preview output for each route, checked in the platform debuggers. Before: home preview on all six. After: correct preview per route.
- Search Console indexed title per URL. This updates on its own schedule, so treat it as a slow confirmation rather than a same-day check. The URL Inspection tool's crawled-page view is the faster signal, because it shows what the crawler received on the most recent fetch.
The 4,314 static pages are a useful control. They were already correct and should remain correct. If they change after the fix, the build script or the rewrite rules are touching files they should not.
One more check worth running: confirm the rewrite rules do not shadow the static files. Request a route that does not exist and confirm it falls back to the app's not-found handling rather than serving an empty page. The try_files fallback in the nginx snippet above handles this, but it is worth testing explicitly.
Frequently asked questions
Does Google not render JavaScript?
Google does render JavaScript, and has for years. The problem is not that rendering never happens. It is that rendering is a second pass, and the first pass reads the raw HTML. Tags that only exist after rendering can be picked up later, or not at all, depending on the page and the crawl budget. Other consumers, including most social preview fetchers, do not render at all. The reliable approach is to put the tags in the HTML so the answer does not depend on which pass is reading the page.
Why did the browser show the correct title if the HTML was wrong?
Because the browser ran the useEffect and updated the DOM after load. The browser is showing you the rendered state. The crawler that reads only the response body never sees that state. This is why view source, not the rendered inspector, is the check that matters for this class of problem. Any tool that executes JavaScript will agree with the browser and hide the defect.
Can I keep the useEffect that sets the metadata?
Yes. It still does useful work for client-side navigation, where the page changes without a full reload and the tags need to update. What changes is that it is no longer the only place the tags exist. The static HTML covers the initial load and everything that reads the response body. The effect covers in-app navigation. The two do not conflict as long as they set the same values.
Is the canonical tag pointing at the wrong URL a separate problem?
It is the same problem with a worse consequence. A canonical pointing at the home URL on every route tells the crawler that all six pages are duplicates of the home page. That is a stronger signal than a wrong title, because it is an explicit instruction about which URL should be indexed. Fixing the title and description without fixing the canonical leaves the more serious issue in place. The post-build script above replaces all of them together for that reason.
For more on how this fits into a broader technical SEO review, see the web development notes.
Frequently asked questions
Google does render JavaScript, and has for years. The problem is not that rendering never happens. It is that rendering is a second pass, and the first pass reads the raw HTML. Tags that only exist after rendering can be picked up later, or not at all, depending on the page and the crawl budget. Other consumers, including most social preview fetchers, do not render at all. The reliable approach is to put the tags in the HTML so the answer does not depend on which pass is reading the page.
Because the browser ran the useEffect and updated the DOM after load. The browser is showing you the rendered state. The crawler that reads only the response body never sees that state. This is why view source, not the rendered inspector, is the check that matters for this class of problem. Any tool that executes JavaScript will agree with the browser and hide the defect.
Yes. It still does useful work for client-side navigation, where the page changes without a full reload and the tags need to update. What changes is that it is no longer the only place the tags exist. The static HTML covers the initial load and everything that reads the response body. The effect covers in-app navigation. The two do not conflict as long as they set the same values.
It is the same problem with a worse consequence. A canonical pointing at the home URL on every route tells the crawler that all six pages are duplicates of the home page. That is a stronger signal than a wrong title, because it is an explicit instruction about which URL should be indexed. Fixing the title and description without fixing the canonical leaves the more serious issue in place. The post-build script above replaces all of them together for that reason.