Your React / Vue / Angular SPA sets each page title via document.title = "..." or a <Helmet> / <Head> component after the route mounts. In the browser the tab title updates correctly, but in Google every URL shows the same generic title in the SERP: MyApp, Loading..., or whatever the server put in the initial HTML.
Fastest fix: move the title into the server-rendered (or pre-rendered) HTML so the correct <title> is present in the very first byte of the response, before any JavaScript runs. Verify with curl of the raw URL. Everything below is the long way around if that one change is not enough.
Why this happens: Google indexes JavaScript pages in two waves. Wave 1 reads the raw HTML the server returned. Wave 2 runs your JavaScript in a headless Chromium and re-reads the rendered DOM. As of June 2026 the gap between the two waves still ranges from a few hours to several weeks depending on crawl budget, and Wave 2 is not guaranteed for every URL. A title that only exists after JS runs is at the mercy of Wave 2. A title in the raw HTML is indexed on Wave 1, every time. (Google’s own JavaScript-SEO documentation describes this two-stage crawl-then-render model.)
There is a second reason that matters more every quarter: AI crawlers do not render JavaScript at all. As of June 2026, GPTBot, ClaudeBot, PerplexityBot and similar agents send an HTTP request, read the raw HTML, and move on — they do not run a browser engine, do not wait for rendering, and do not make a second pass. A widely-cited Vercel/MERJ analysis of over 500 million GPTBot fetches found zero evidence of JavaScript execution, and the same pattern held for ClaudeBot, Meta’s crawler, and PerplexityBot. A JS-only title is invisible to all of them, so your pages get a generic or empty title in AI Overviews and chatbot citations.
Common causes
Ordered by hit rate on real SPAs.
1. Server returns the same <title> shell for every route
The initial HTML on every URL contains <title>MyApp</title>. JS replaces it after mount. Google indexes the shell on Wave 1 and may never reach Wave 2.
How to spot it: curl https://example.com/any/route returns the same <title> on every URL.
2. Initial title is “Loading…” or empty
The shell ships <title>Loading...</title> to signal hydration is pending. Google takes it literally and puts Loading... in the SERP.
How to spot it: search site:yourdomain.com in Google; many results share the same Loading... title.
3. Title set by a client-only state hook
In React, useEffect(() => { document.title = data.title }, [data]) only runs in the browser. Server rendering never executes the effect.
How to spot it: the title is correct in DevTools after hydration but missing from view-source: and curl.
4. SSR streams the head before the data fetch finishes
A streaming-SSR setup flushes <head> to the wire before the title data resolves, so the flushed HTML has no title.
How to spot it: the HTML response shows <title></title> or no title at all in the head; the browser shows the correct title only after the fetch completes.
5. Title set after a client-side redirect
/old-path redirects with window.location / router push (not an HTTP 301) to /new-path. Google crawls /old-path, gets the pre-redirect shell title, and indexes that.
How to spot it: the SERP shows the pre-redirect URL with a generic title; the redirect path returns HTTP 200 instead of 301.
6. Crawler renders, but the title hook depends on cookies or auth
The title fetch needs a logged-in API call. Googlebot is anonymous, the API returns 401, and the title state never updates.
How to spot it: the network panel of a crawler simulator (or curl with no cookies) shows 401 on the title-data endpoint.
Which bucket am I in?
What curl shows in <title> | What the browser tab shows | Most likely cause | Go to |
|---|---|---|---|
| Same value on every route | Correct per route | Shell title, JS replaces it (#1) | Step 2 |
Loading... or empty | Correct after a beat | Placeholder shell (#2) | Step 2 |
| Empty on dynamic routes only | Correct after fetch | Client-only hook or streamed head (#3, #4) | Step 2 / 3 |
| Generic, and URL is a stale path | Correct on the real path | Client-side redirect (#5) | Step 5 |
Generic, 401 on a data call | Correct only when logged in | Auth-gated title data (#6) | Step 6 |
Before you start
- Confirm the symptom:
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" https://example.com/some-routeand read the<title>. - Decide your rendering strategy: full SSR, static prerender (SSG), or hybrid. Each unblocks a different fix path.
- Check whether your framework already supports server-side metadata: Next.js
generateMetadata, NuxtuseHead/useSeoMeta, SvelteKit<svelte:head>, AngularTitleservice with@angular/ssr. - Note how many distinct route templates need fixing. Often one shared layout drives all of them.
Information to collect
- Server-rendered HTML for 5-10 representative URLs (the
curloutput, not DevTools). - The browser DOM after hydration for the same URLs, to confirm the client-side title is correct.
- Whether your framework supports SSR / SSG and whether it is actually enabled.
- The data source for titles: frontmatter, an API call, a route param, or a CMS.
- Any redirects between the SERP-indexed URL and the canonical URL.
Step-by-step fix
Ordered by impact and cost.
Step 1: Verify exactly what the crawler sees
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)" \
https://example.com/products/widget \
| grep -oE '<title>[^<]*</title>'
If this returns the wrong title, the problem is in the raw HTML and Wave 2 is irrelevant. To see Google’s actual rendered snapshot, open Search Console, run URL Inspection on the URL, click Test Live URL, then View Tested Page. The right pane has three tabs: HTML, Screenshot, and More Info. The HTML tab is the post-render DOM Google indexes; the More Info tab lists blocked resources and console errors. (For an already-indexed URL, use View Crawled Page instead, which shows the last stored render.)
Step 2: Move title generation server-side
This is the real fix for causes #1-#4. Emit the title during SSR so it is in the raw HTML.
In Next.js App Router (Next.js 15+, where params is async):
// app/products/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const product = await getProduct(slug);
return {
title: `${product.name} | Acme Store`,
description: product.summary,
};
}
In Nuxt 3:
useSeoMeta({
title: () => `${product.value.name} | Acme Store`,
description: () => product.value.summary,
});
In SvelteKit (with data loaded in +page.ts / +page.server.ts):
<svelte:head>
<title>{data.product.name} | Acme Store</title>
</svelte:head>
In Angular (with @angular/ssr / server-side rendering enabled):
import { Title } from "@angular/platform-browser";
constructor(private title: Title) {}
ngOnInit() { this.title.setTitle(`${this.product.name} | Acme Store`); }
All of these run during SSR and put the correct title in the response HTML. Make sure your await chain resolves the title data before the head is flushed (this also closes cause #4).
Next.js gotcha (15.2+): for dynamically rendered routes, Next.js now streams metadata and appends the resolved <title> / <meta> tags to the end of the <body>, not the initial <head> — to cut TTFB. Googlebot (which executes JS and reads the full DOM) handles this fine, and Next.js detects “HTML-limited bots” by User-Agent and falls back to blocking so the tags land in <head> for them. But the cleanest fix is to keep the route statically prerenderable: when generateMetadata introduces no runtime data (cookies(), headers(), searchParams), the title is baked into the initial HTML for everyone. If you must render dynamically and want the title in raw <head> for every crawler, set htmlLimitedBots: /.*/ in next.config.ts to disable streaming (at a TTFB cost). See Next.js streaming-metadata docs.
Step 3: Prerender static routes when SSR is overkill
For content that does not change per request, pre-generate static HTML at build time. No runtime server, fully crawler-safe.
// next.config.* / nuxt.config.* / astro.config.*
export default { output: "static" };
In Next.js App Router, dynamic routes prerender when you supply generateStaticParams. Static prerender embeds the title in the HTML at build time.
Step 4: For pure SPA frameworks, add a build-time prerender step
If you cannot adopt SSR/SSG, crawl your own build and write static HTML per route. As of June 2026, pick a maintained tool:
- Vike (formerly
vite-plugin-ssr; the npm package is nowvike) — pre-render mode for Vite apps, actively maintained. prerender-spa-pluginv3 — Puppeteer-based (the old v2 used the deprecated PhantomJS); for Webpack builds.presiteorprerenderer— minimal, framework-agnostic.
Note: the once-popular react-snap is effectively unmaintained as of June 2026 — usable but not recommended for new setups. Wire whichever you choose into a postbuild step:
"scripts": {
"build": "vite build",
"postbuild": "vike prerender"
}
Step 5: Replace client-side redirects with HTTP 301s
If /old-path should redirect, do it at the server / edge, not in JS:
# Netlify _redirects (or Cloudflare/Vercel rules, or Express res.redirect(301, ...))
/old-path /new-path 301
The crawler follows the 301 directly and indexes /new-path with its correct title. A client-side redirect leaves Google on /old-path with the shell title.
Step 6: Make title data accessible without auth
If the title depends on auth-gated data, ship a public fallback for the anonymous crawler:
const title = isAuthed
? `${user.name}'s Dashboard | Acme`
: "Sign In to Acme";
Or move the auth-gated text into the body and keep the <title> public. Do not branch on the user-agent to serve different titles to bots — that is cloaking (see the FAQ).
Step 7: Request reindexing on fixed URLs
In Search Console, run URL Inspection on your highest-traffic affected URLs and click Request Indexing. SERP titles typically refresh within 1-2 weeks after a successful recrawl; long-tail URLs can take a month or more.
How to confirm it’s fixed
curlof any route returns the route-specific title in the raw HTML (not just in DevTools).- Search Console URL Inspection -> View Tested Page -> HTML tab shows the correct
<title>. - A
site:yourdomain.comsearch shows distinct, route-specific titles for each result. - Click-through rate recovers as relevant titles appear in the SERP.
- Optional: fetch as an AI crawler, e.g.
curl -A "GPTBot" https://example.com/some-route | grep -i '<title>', and confirm the title is present in the raw HTML (these bots do not run JS).
Long-term prevention
- Default to server-side metadata APIs (
generateMetadata,useSeoMeta,<svelte:head>, AngularTitle) for every route from day one. - Forbid
document.title = "..."as the only title source for production routes in code review. - Add a CI assertion that
curlof each route returns a unique, non-placeholder<title>. - Run Lighthouse SEO or a headless
curlcheck in CI to catch missing titles on new routes before they ship. - Document the title-source pattern in your component library so new contributors follow it automatically.
Common pitfalls
- “Googlebot renders JavaScript, so it should be fine.” It does on Wave 2, but Wave 2 runs on a delay and is not guaranteed per URL — and
GPTBot/ClaudeBot/PerplexityBotdo not render JS at all. Server-rendered metadata is the only path that satisfies every crawler on the first fetch. - Setting a framework-level default title (
MyApp) and forgetting it overrides per-route titles in some routing scenarios. - Relying on
<Helmet>in a React Router SPA without SSR —<Helmet>only mutatesdocument.titlein the browser. - Using
router.events.on("routeChangeComplete", updateTitle)— also browser-only. - Reaching for Google’s old “dynamic rendering” workaround (serving a prerendered copy to bots via Rendertron/Prerender.io). Google’s own docs now label this “a workaround and not a long-term solution” and steer you to SSR, SSG, or hydration instead. See Google’s dynamic rendering doc.
FAQ
Q: Does Googlebot run JavaScript at all?
Yes, on Wave 2, for most URLs eventually. But the first indexing pass uses raw HTML, and JS-only titles surface only on the second pass, which can take days or weeks (as of June 2026). Server-rendered titles index on the first pass.
Q: My framework is SPA-only. Do I have to migrate to Next.js or Nuxt?
No. Add a build-time prerender step (Vike, prerender-spa-plugin v3, presite) to write static HTML per route. That fixes title indexing without a framework migration.
Q: How long after fixing the title will SERPs update?
Typically 1-2 weeks. High-traffic URLs recrawl fastest; long-tail can take a month or more. Use Request Indexing on critical pages to speed it up.
Q: Can I keep a “Loading…” title for users but serve a real title to Googlebot?
No. Serving different content to crawlers and users is cloaking and violates Google’s spam policies. Ship the real title to everyone. If a loading state is a UX need, render the real title in the SSR HTML and only show the spinner in the body.
Q: Do ChatGPT and Claude see my JS-set title?
No. As of June 2026 the major AI crawlers (GPTBot, ClaudeBot, PerplexityBot) fetch HTML but do not execute client-side JavaScript, so a JS-only title is invisible to them. Verify with curl -A "GPTBot" <url> — what you see in that raw response is exactly what they index. This is another reason to put the title in the raw HTML.
Q: I’m on Next.js App Router and my title is correct in curl for static pages but missing for dynamic ones. Why?
Since Next.js 15.2, dynamically rendered routes stream metadata into the <body> after the initial response instead of putting it in <head>. JS-running bots (Googlebot) read it; non-JS crawlers may not. Either keep the route statically prerenderable (no cookies() / headers() / searchParams in generateMetadata) so the title is baked into the HTML, or set htmlLimitedBots: /.*/ in next.config.ts to force the title into <head> for all crawlers.