You ship a React / Vue / SPA application. Each route sets the page title via document.title = "..." or a <Helmet> component after the route mounts. In the browser the title bar updates correctly. On Google, every URL in the SERP shows the same generic title — “MyApp” or “Loading…” or whatever the server returned in the initial HTML. The crawler never executed the title-setting code, or did but indexed the pre-update HTML snapshot. The fix is to ship the correct title in the server-rendered HTML or to use a hybrid rendering strategy.
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 HTML and never gets the per-route title.
How to spot it: curl https://example.com/any/route and every URL returns the same <title>.
2. Initial title is “Loading…” or empty
The shell is <title>Loading...</title> to signal hydration is pending. Google takes that literally and ships “Loading…” into the SERP.
How to spot it: Search site:yourdomain.com in Google. Many results have the same “Loading…” title.
3. Title set by client-only state hook
In React, useEffect(() => { document.title = data.title }, [data]) only runs in the browser. Server rendering bypasses the effect.
How to spot it: Title is correct in browser DevTools after hydration; missing in view-source and curl.
4. SSR pipeline returns a partial render before the title hook fires
A streaming SSR setup sends the <head> to the wire before the data fetch completes. Title is missing from the flushed HTML.
How to spot it: HTML response shows <title></title> or no title at all in the head; browser shows correct title post-fetch.
5. Title set after a redirect
URL /old-path redirects via JS (not HTTP 301) to /new-path. Google crawls /old-path, gets the pre-redirect shell title, indexes that.
How to spot it: SERP shows the pre-redirect URL with a generic title; HTTP 200 instead of 301 on the redirect path.
6. Crawler renders, but title hook depends on cookies or auth
The title fetch requires a logged-in API call. Googlebot is anonymous; the API returns 401; the title state never updates.
How to spot it: Network panel of crawler-simulator (or curl with no cookies) shows 401 on the title-data endpoint.
Before you start
- Confirm the symptom with
curl -A "Mozilla/5.0 (compatible; Googlebot/2.1)" https://example.com/some-routeand inspect<title>. - Decide your rendering strategy: full SSR, SSG, or hybrid? Each unblocks a different fix path.
- Check whether your framework already supports server-side metadata (Next.js
metadata, NuxtuseHead, SvelteKit<svelte:head>). - Note how many distinct route templates need fixing. Often one shared layout drives all.
Information to collect
- Server-rendered HTML for 5-10 representative URLs.
- Browser DOM after hydration for the same URLs (to confirm the client-side title is correct).
- Whether the framework supports SSR / SSG and whether you have it enabled.
- The data source for titles (frontmatter, API call, route param).
- Any redirects between the SERP-indexed URL and the canonical URL.
Step-by-step fix
Ordered by impact and cost.
Step 1: Verify what Googlebot actually sees
curl -s -A "Mozilla/5.0 (compatible; Googlebot/2.1)" \
https://example.com/products/widget \
| grep -oE '<title>[^<]*</title>'
Or use Search Console → URL Inspection → View crawled page to see the rendered HTML Google uses for indexing.
Step 2: Move title generation server-side
In Next.js App Router:
// app/products/[slug]/page.tsx
export async function generateMetadata({ params }) {
const product = await getProduct(params.slug);
return {
title: `${product.name} | Acme Store`,
description: product.summary,
};
}
In Nuxt 3:
useHead({ title: () => `${product.value.name} | Acme Store` });
In SvelteKit:
<svelte:head>
<title>{product.name} | Acme Store</title>
</svelte:head>
These run during SSR and emit the correct title in the response HTML.
Step 3: Prerender static routes when SSR is overkill
For content that does not change per request, pre-generate static HTML:
// next.config / nuxt.config / astro.config
export default { output: 'static' };
Static prerender embeds the title in HTML at build time. No runtime cost, fully crawler-safe.
Step 4: For pure SPA frameworks, add a build-time prerender step
Tools like prerender-spa-plugin (Vue / Webpack) or react-snap crawl your dev server and write static HTML for each route. The resulting HTML contains the correct title.
npm install --save-dev react-snap
# add postbuild hook
"scripts": {
"build": "vite build && react-snap"
}
Step 5: Replace JS redirects with HTTP 301s
If /old-path should redirect, use server-level redirects (Netlify _redirects, Cloudflare rules, Express middleware):
/old-path /new-path 301
The crawler follows the 301 directly and indexes /new-path with its correct title.
Step 6: Make title-data accessible without auth
If the title text depends on data behind auth, ship a public version for SSR:
const title = isAuthed
? `${user.name}'s Dashboard | Acme`
: `Sign In to Acme`;
Or shift the auth-gated text to the body, keeping the title public.
Step 7: Request reindexing on fixed URLs
In Search Console → URL Inspection → Request indexing for the highest-traffic affected URLs. SERP titles typically update within 1-2 weeks after a successful recrawl.
Verify
curlof any route returns the route-specific title in the initial HTML.- Search Console → URL Inspection shows the correct title in the rendered HTML snapshot.
- A
site:yourdomain.comsearch shows distinct, route-specific titles for each result. - Click-through rate recovers as relevant titles appear in SERP.
- Your framework’s metadata APIs run during SSR and the output is consistent across deploys.
Long-term prevention
- Default to server-side metadata APIs (
generateMetadata,useHead,<svelte:head>) for every route from day one. - Forbid
document.title = "..."patterns in code review for production routes. - Add a CI assertion that
curlof each route returns a unique, non-placeholder<title>. - Use Lighthouse or Search Console’s URL Inspection in CI to catch missing titles on new routes.
- Document the title-source pattern in your component library so new contributors follow it automatically.
Common pitfalls
- Assuming “Googlebot renders JavaScript so it should be fine.” It does, but the rendering tier runs on a delay and not for every URL. Server-rendered metadata is far more reliable.
- Setting a default title at the framework level (“MyApp”) and forgetting it overrides per-route titles in some routing scenarios.
- Relying on
<Helmet>in React Router SPA without SSR.<Helmet>only updatesdocument.titlein the browser. - Using
nextRouter.events.on('routeChangeComplete', updateTitle)— this also runs only in the browser. - Caching the title-data endpoint with auth so crawlers get a 401 but real users get 200. Make title fetches public-friendly.
FAQ
Q: Does Googlebot run JavaScript at all?
Yes — for most URLs eventually. But the initial indexing pass uses raw HTML; JS-only titles surface on the second pass, which can take days or weeks. Server-rendered titles index immediately.
Q: My framework is SPA-only. Do I need to switch to Next.js or Nuxt?
Not necessarily. Use a prerender step (react-snap, vite-plugin-ssr, prerender-spa-plugin) to write static HTML at build time. That fixes the title indexing problem 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. Request indexing on critical pages to accelerate.
Q: Can I keep the “Loading…” shell title for users while serving a real title to Googlebot?
No — cloaking different content to crawlers vs. users violates Google’s guidelines. Ship the real title to everyone. If “Loading…” is a UX requirement, replace it the moment hydration starts, but ensure the SSR-rendered HTML already has the real value.