Using Unhead in a Single Page Application
Unhead updates the document after your SPA starts in the browser. Those updates do not change the HTML response from your server or CDN.
This distinction matters for public pages. Google can render JavaScript, but rendering may be delayed, and not every crawler runs JavaScript. Social preview crawlers commonly read the response HTML and stop there.
Start with site-wide metadata in the SPA's HTML template. If public routes need their own metadata, prerender them.
Private dashboards and other authenticated apps rarely need prerendering. Template defaults are usually enough for them.
Add Metadata to the SPA Template
Start with useful defaults in the index.html file that your host serves for SPA routes:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Acme</title>
<meta name="description" content="Plan and publish your work with Acme.">
<meta property="og:type" content="website">
<meta property="og:title" content="Acme">
<meta property="og:description" content="Plan and publish your work with Acme.">
<meta property="og:image" content="https://example.com/social-card.png">
<meta property="og:image:alt" content="Acme">
<meta name="twitter:card" content="summary_large_image">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
Keep the charset near the start of <head>. Use an absolute URL for the social image.
These values must make sense for every route that receives this file. If the same index.html serves /, /about, and /pricing, do not put a homepage canonical URL in the shared template. Add route-specific canonical and Open Graph URLs through Unhead, then prerender those routes.
Set Route Metadata with Unhead
Continue to use Unhead for navigation inside the app:
import { useHead } from 'unhead'
useHead(unheadInstance, {
title: 'Pricing | Acme',
meta: [
{
name: 'description',
content: 'Compare Acme plans and pricing.'
},
{ property: 'og:title', content: 'Pricing | Acme' },
{
property: 'og:description',
content: 'Compare Acme plans and pricing.'
},
{ property: 'og:url', content: 'https://example.com/pricing' }
],
link: [
{ rel: 'canonical', href: 'https://example.com/pricing' }
]
}, {
onRendered() {
document.documentElement.dataset.prerenderReady = window.location.pathname
}
})
The onRendered callback runs after Unhead has patched the DOM. The marker gives a prerender script a reliable point to capture the page.
For an async route, expose the marker only after its content and head data are ready. A build that captures the loading title is still a broken build.
Prerender Routes with JavaScript
If your framework already has static generation, use it. The script below is for an existing Vite SPA: Playwright runs the built app, waits for Unhead, and saves the rendered HTML.
Install Playwright and its Chromium binary:
pnpm add -D playwright
pnpm exec playwright install chromium
Add a script:
import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { chromium } from 'playwright'
import { preview } from 'vite'
const routes = ['/', '/about', '/pricing']
const port = 4173
const origin = `http://127.0.0.1:${port}`
function outputFile(route) {
return route === '/'
? resolve('dist/index.html')
: resolve('dist', route.slice(1), 'index.html')
}
function closeServer(server) {
return new Promise((resolveClose, rejectClose) => {
server.httpServer.close((error) => {
if (error)
rejectClose(error)
else
resolveClose()
})
})
}
async function renderRoute(browser, route) {
const page = await browser.newPage()
await page.goto(new URL(route, origin).href, {
waitUntil: 'networkidle',
})
await page.waitForFunction(
expectedRoute =>
document.documentElement.dataset.prerenderReady === expectedRoute,
route,
)
await page.evaluate(() => {
document.documentElement.removeAttribute('data-prerender-ready')
})
const file = outputFile(route)
await mkdir(dirname(file), { recursive: true })
await writeFile(file, await page.content())
await page.close()
}
const server = await preview({
preview: {
host: '127.0.0.1',
port,
strictPort: true,
},
})
await chromium.launch()
.then(browser =>
Promise.all(routes.map(route => renderRoute(browser, route)))
.finally(() => browser.close()),
)
.finally(() => closeServer(server))
Run it after Vite builds the SPA:
{
"scripts": {
"build": "vite build && pnpm prerender",
"prerender": "node scripts/prerender.mjs"
}
}
The output contains one document per route:
dist/
โโโ index.html
โโโ about/
โ โโโ index.html
โโโ pricing/
โโโ index.html
Configure your host to serve these files before its SPA fallback. An unconditional rewrite from every URL to /index.html bypasses the prerendered documents.
Keep the route list in sync with your router or generate it from the same route data. Prerendering is a poor fit when routes change on every request, depend on a signed-in user, or number in the tens of thousands. Use SSR for those pages.
Check the Response HTML
Build the app, then inspect the generated file without opening a browser:
pnpm build
rg '<title>|description|canonical|og:' dist/pricing/index.html
After deployment, check the response too:
curl -s https://example.com/pricing \
| rg '<title>|description|canonical|og:'
You can also run the Unhead CLI over every generated page:
pnpm dlx @unhead/cli validate-html 'dist/**/*.html'
View Source is the final check. DevTools shows the live DOM after Unhead runs, which can hide a missing prerender step.