Skip to content
intermediate Phase 15 · Production Builds and Capstone Project

Titles, Meta, and SPA SEO

Update document head per route and handle crawler limitations of SPAs.

40m
0 problems
Topic Progress 0%

Every Tab Says "App": Per-Route Titles and Meta From the Router

Your product page renders beautifully — and the browser tab says "App", the same as every other route. Share the link in Slack and the unfurl is the brand name with a stock image. Three related failures with one root cause: in a single-page app, the <head> is written once at build time and nothing you do inside a route component changes what the document declares.

The first half of the fix is mechanical: make each route declare its own metadata and teach the router to apply it after every navigation.

// router/index.js
const routes = [
  { path: '/products/:id', component: () => import('../views/ProductView.vue'),
    meta: { title: 'Product', description: 'View product details and stock.' } },
  { path: '/about', component: () => import('../views/AboutView.vue'),
    meta: { title: 'About', description: 'Who we are and how to reach us.' } }
]

router.afterEach((to) => {
  const site = import.meta.env.VITE_BRAND_NAME || 'Acme Shop'
  document.title = to.meta.title ? `${to.meta.title} · ${site}` : site
  const desc = document.querySelector('meta[name="description"]')
  if (desc && to.meta.description) desc.setAttribute('content', to.meta.description)
})

afterEach runs after the navigation is confirmed — including browser back/forward — which is why it beats sprinkling document.title = across onMounted hooks. Note the branded default comes from a prefixed environment variable: the same substitution rules from Environment Variables in Vite apply to head defaults.

But afterEach + querySelector gets uncomfortable the moment titles depend on data. Your product page cannot set "<name> · Acme Shop" until the fetch resolves, and it must reset if the user navigates away mid-fetch. This is the point where hand-rolled DOM calls get replaced by a head manager:

// npm i @unhead/vue  (the maintained successor of @vueuse/head)
import { createHead, useHead } from '@unhead/vue'
app.use(createHead())

// inside ProductView.vue
const product = ref(null)
onMounted(async () => { product.value = await fetchProduct(route.params.id) })

useHead({
  title: computed(() => product.value
    ? `${product.value.name} · Acme Shop` : 'Loading…'),
  meta: [
    { name: 'description',
      content: computed(() => product.value?.blurb.slice(0, 155) ?? '') },
    { property: 'og:title', content: computed(() => product.value?.name ?? '') },
    { property: 'og:type', content: 'product' },
    { property: 'og:image', content: computed(() =>
        product.value ? `https://shop.acme.test/og/${product.value.id}.png` : '') }
  ]
})

A head manager earns its dependency four ways: it dedupes competing title writes (last reactive value wins, one <title> in the DOM), reconciles tags instead of blindly appending, removes component-owned tags on unmount, and — decisively — the same useHead calls can render into server HTML later, which is the bridge to the SSR story.

The snippet above also carries the conventions search and social tooling expect, worth keeping as a table:

Tag Convention Typical limit
<title> Page · Brand, unique per route ≤ 60 chars
meta description written pitch, no keyword lists 150–160 chars
og:image absolute URL, 1200×630 px under ~1 MB, PNG/JPG
twitter:card summary_large_image —

Two cautions. og:image must be an absolute URL — crawlers fetching your page cannot resolve your relative paths against their own origin. And put the brand at the end of the title string: search results truncate long titles, and the page-specific words must survive the cut. Titles that resolve from data are also a UX fix, not an indexing guarantee: the raw HTML a crawler sees on first fetch still contains the build-time head. Who reads which HTML is the entire next chapter.

Crawlers With and Without JavaScript: What Googlebot Waits For and What Slack Never Runs

You ship the head management from the previous chapter, run the URL through a social sharing debugger — and the preview card is still the generic one. The meta tags are definitely in your code. The problem is when different crawlers read them.

Two audiences, two entirely different capabilities. Googlebot renders JavaScript. It fetches your index.html, executes the bundle in a headless Chrome, and indexes the resulting DOM — but rendering happens in a queue, separately from crawling, and for large sites it is budgeted and delayed: pages can be indexed from the rendered state hours or days later, and content that appears only after client-side fetches is a known source of "why is this page not indexed" tickets. Link unfurlers do not run JavaScript at all. Twitterbot/X, Facebook, LinkedIn, Slack, and Discord fetch the raw HTML, parse the og: tags, and stop — a client-rendered SPA hands them an empty <div id="app">, so every product link shares one generic card, permanently.

That split is why the delivery strategy is a product decision, not a performance footnote:

Strategy Raw HTML contains Who sees full content Cost
Pure CSR (what you have now) empty app shell Googlebot (late); no unfurlers none
Prerendering (vite-ssg, prerender plugins) complete rendered HTML + head, generated per route at build everyone, on first fetch build-time route list
SSR (Vue server renderer / Nuxt) rendered HTML per request everyone, dynamically server runtime, hydration care
Deprecated "dynamic rendering" (bot-specific HTML) varies by user agent fragile, discouraged penalty risk

Prerendering is the pragmatic default for content pages. vite-ssg builds from your existing router table and emits one directory of static HTML per route:

// main.js — same app, SSG-compatible entry
export default createSSGApp(App, router, (ctx) => {
  // register head/metadata here so it renders into each static page
})
npx vite-ssg build        # dist/ gains /about/index.html, /products/index.html, …

The constraint is arithmetic: a prerenderer can only emit pages whose URLs it knows at build time. Static routes come straight from the router table; dynamic ones (/products/:id) need the ID list supplied at build — a slugs fetch in the SSG build step for a catalog of hundreds, which stops being practical in the tens of thousands. Past that line the answer is SSR, which is why Server-Side Rendering and Hydration exists as its own topic and why Nuxt packages both modes behind route rules.

Prerendered pages also carry a correctness obligation: the HTML and the first client render must agree, or hydration logs mismatches and re-renders — with head content, the visible symptom is a title flicker as the client takes over. Reactive useHead values that depend only on route + fetched-at-build data avoid this; anything reading window during render will not.

Choose deliberately per page type. Behind-login app screens are invisible to all crawlers — pure CSR is fine and saves build time. Marketing, docs, blog, and product pages are fetched raw by unfurlers and budgeted by Googlebot — prerender them. Hybrid is normal: an SPA entry with prerendered content routes, deployed on a host that can serve directory-index HTML (which pairs with the rewrite rules you will meet in Deploying a Single-Page App once this path reaches production hosting).

One test tells you where you stand for any URL:

curl -s https://shop.acme.test/products/42 | head -40

If the answer is an empty app div, that page's metadata exists only for clients that can run your JavaScript. Everything else — sitemap, canonicals, structured data — is worthless until this raw HTML tells the truth.

Feeding the Index: Generated sitemap.xml, robots.txt, Canonicals, and Honest 404s

Your prerendered pages now carry real titles and Open Graph tags. The remaining work is logistics: telling the index which URLs exist, keeping that list current as the catalog changes, and making sure the URLs it should never rank resolve as honest 404s.

The sitemap is generated, not edited. A hand-maintained sitemap.xml goes stale the first week; generate it from the two sources that actually know your URLs — the router table and your data:

// scripts/gen-sitemap.mjs — runs in CI after the build, before deploy
const SITE = 'https://shop.acme.test'
const staticRoutes = ['/', '/about', '/products', '/contact']   // mirror your router table
const productSlugs = await db.listPublishedSlugs()               // or an API call

const urls = [...staticRoutes, ...productSlugs.map(s => `/products/${s}`)]
const xml = [
  '<?xml version="1.0" encoding="UTF-8"?>',
  '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
  ...urls.map(u =>
    `  <url><loc>${SITE}${u}</loc><lastmod>${new Date().toISOString().slice(0, 10)}</lastmod></url>`),
  '</urlset>'
].join('\n')

fs.writeFileSync('dist/sitemap.xml', xml)

Three rules the format enforces: <loc> must be absolute (the same constraint as og:image), one file caps at 50,000 URLs or 50 MB — beyond that, a sitemap index lists sub-sitemaps — and lastmod should reflect real content changes, not your deploy date, or it stops being a useful signal. Publish the pointer where crawlers look for it:

# dist/robots.txt
User-agent: *
Allow: /
Disallow: /admin
Disallow: /checkout
Sitemap: https://shop.acme.test/sitemap.xml

Know what robots.txt is for: it controls crawling, not indexing. A disallowed URL referenced from elsewhere can still be indexed without its content; keeping a page out requires noindex — a meta tag (now easy with useHead) or an X-Robots-Tag header. robots.txt saves crawl budget on internal screens; it is not a privacy boundary and no substitute for authorization.

Canonicals collapse your duplicate URLs. /products/42, /products/42?sort=asc, and http://shop.acme.test/products/42/ are three URLs for one page — diluting signals across variants. One line in the head manager fixes the class of problem:

useHead({ link: [{ rel: 'canonical',
                   content: `https://shop.acme.test${route.path}` }] })

Use route.path, not route.fullPath: the canonical should ignore query strings you consider incidental (sorting, tracking parameters) — otherwise every shared link becomes its own canonical and defeats the purpose.

Then there is the 404 that isn't. The SPA fallback you will configure serves index.html for every unknown path — including /this-page-never-existed — so crawlers record a soft 404: a "page" that returns 200, renders your app shell, and has no content. Prerendered deployments can fix this properly, because the set of real URLs is known: the server serves the app for known routes and a genuine 404 status for the rest, with your prerendered 404.html shown to humans:

error_page 404 /404.html;   # status stays 404, body stays helpful

If your product pages carry structured entities, add JSON-LD — a <script type="application/ld+json"> block with @type: Product, name, price, availability — rendered into the prerendered HTML via useHead({ script: [...] }). Rich results depend on it, and it is one of the few places where the machine-readable version of the page must match the visible one.

Verify the whole chain the way a dumb crawler would: curl the raw HTML and check title, description, canonical, and og: tags are present before any JavaScript runs, then submit the sitemap in Search Console and watch the URL Inspection report. With the head, the delivery strategy, and the discovery files aligned, the only lever left is the artifact itself — and Production Build Optimization is where those prerendered megabytes get minified, mapped, and budgeted.

Quiz

1. Your SPA's product pages set og:title and og:image via useHead after the data loads, but Slack and X still show a generic preview card. Most likely cause?

Question 1 options

2. A `router.afterEach` hook sets document.title from route.meta.title. What does this fix, and what does it leave broken?

Question 2 options

3. Which sitemap.xml practice is correct for a growing product catalog?

Question 3 options

4. Your SPA fallback serves index.html with status 200 for /this-page-never-existed. What is the SEO problem and the proper fix?

Question 4 options

Flashcards

Question

How does a Vue SPA set a different document title per route?

Answer

Declare `meta.title` on each route and apply it in `router.afterEach((to) => document.title = ...)` — it runs after every confirmed navigation including back/forward. For titles that depend on fetched data, use reactive `useHead({ title: computed(...) })` in the view instead of one-shot assignments.

Question

What does a head manager (@unhead/vue) give you over raw document manipulation?

Answer

Deduped reactive updates (one <title> even when several components set it), tag reconciliation instead of blind appends, cleanup on unmount, and — decisively — the same useHead calls can render into server HTML for SSR/prerender, so metadata works in both delivery modes.

Question

Which crawlers execute JavaScript and which do not, for SPA metadata?

Answer

Googlebot renders JS but in a separate, budgeted wave — indexing can lag hours/days and client-fetched content may be missed. Social unfurlers (X/Twitterbot, Facebook, LinkedIn, Slack) never run JS: og/twitter tags must exist in the raw HTML, which means prerendering or SSR.

Question

What are the hard requirements for a correct sitemap.xml?

Answer

Absolute <loc> URLs, ≤50,000 URLs / 50 MB per file (beyond that, a sitemap index), honest lastmod values, and generation from the route table + dynamic data in CI — published via robots.txt `Sitemap:` and Search Console. robots.txt controls crawling, not indexing; noindex controls indexing.

Question

What is a soft 404 in an SPA and how do you eliminate it?

Answer

The fallback serves index.html with status 200 for URLs that do not exist, so crawlers index a phantom page. Fix: with prerendering the valid URL set is known — serve a real 404 status for unknown paths (nginx `error_page 404 /404.html`) while real routes keep the SPA fallback; pair with canonical links built from route.path.

Revision Notes

Key Takeaways

  • 1. Route meta plus router.afterEach fixes titles for in-app navigation; data-dependent head content belongs in reactive useHead calls, which dedupe, clean up, and render server-side
  • 2. Crawler capability is the whole game: Googlebot renders JS in a delayed budgeted wave, social unfurlers never run it — so og tags must ship in the raw HTML
  • 3. Prerendering (vite-ssg) is the pragmatic middle: static routes from the router table, dynamic routes from a build-time slug list; beyond that scale the answer is SSR
  • 4. The sitemap is generated in CI from routes plus data, with absolute URLs and honest lastmod; robots.txt routes crawlers to it but cannot de-index anything — that is noindex's job
  • 5. The SPA fallback's universal 200 creates soft 404s; a known URL set lets the server return genuine 404 statuses with a helpful body

Interview Tips

  • • "How do you set page titles in a Vue SPA?" — route meta + afterEach for static titles, useHead with computeds for data-dependent ones; then add the limit: neither reaches the raw HTML, so indexing needs prerender/SSR — that second clause is what separates answers
  • • "Why doesn't Slack show my og tags?" — unfurlers do not execute JavaScript; the tags are written post-hydration. Fix by prerendering head content into the HTML, and mention og:image must be an absolute 1200×630 URL
  • • "What's the difference between robots.txt and noindex?" — robots.txt stops crawling (the bot never reads the page; it can still be indexed via links), noindex stops indexing (requires fetching the page); use each for its job
  • • "Your SPA returns 200 for every URL — what's the risk?" — soft 404s and duplicate-content variants; the fix set is canonical links from route.path plus real 404 statuses for unknown paths once the URL set is known

Cheat Sheet

Titles, Meta, and SPA SEO

Per-route head (client):

// static: apply after every confirmed navigation
router.afterEach((to) => { document.title = to.meta.title ? `${to.meta.title} · ${site}` : site })

// data-dependent: reactive head manager (@unhead/vue)
useHead({
  title: computed(() => product.value ? `${product.value.name} · Acme Shop` : 'Loading…'),
  meta: [{ name: 'description', content: computed(() => product.value?.blurb.slice(0, 155) ?? '') },
         { property: 'og:image', content: computed(() => `https://shop.acme.test/og/${id}.png`) }],  // absolute URL
  link: [{ rel: 'canonical', content: `https://shop.acme.test${route.path}` }]   // path, not fullPath
})
Audience Runs JS? Sees client-written head tags?
Googlebot yes — separate, budgeted render wave eventually
X/FB/LinkedIn/Slack bots no never — prerender or SSR

Delivery choice: behind-login screens → pure CSR is fine · content/product pages → vite-ssg build (static routes + build-time slug list) · large dynamic catalogs → SSR/Nuxt. Prerendered HTML must hydrate without mismatch.

Discovery files: generated sitemap.xml (absolute locs, ≤50k URLs/50 MB, real lastmod) + robots.txt (Sitemap: line; controls crawling only — use noindex for indexing). Kill soft 404s: error_page 404 /404.html; with real status while the fallback covers valid routes.

Verify: curl -s <url> | head -40 — title, description, canonical, og: tags must be in the raw HTML before any JS runs.

Continue Learning