--- title: "Using Unhead in a Single Page Application" description: "Ship useful fallback head tags from an SPA, then prerender public routes so crawlers receive route-specific metadata." navigation: title: "Single Page Applications" --- 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](https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics). 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: ```html [index.html] Acme
``` Keep the charset near the start of ``. 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: ```ts import { useHead } from '@unhead/dynamic-import' useHead({ 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](https://playwright.dev/) runs the built app, waits for Unhead, and saves the rendered HTML. Install Playwright and its Chromium binary: ```bash pnpm add -D playwright pnpm exec playwright install chromium ``` Add a script: ```js [scripts/prerender.mjs] 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: ```json [package.json] { "scripts": { "build": "vite build && pnpm prerender", "prerender": "node scripts/prerender.mjs" } } ``` The output contains one document per route: ```text 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: ```bash pnpm build rg '|description|canonical|og:' dist/pricing/index.html ``` After deployment, check the response too: ```bash curl -s https://example.com/pricing \ | rg '<title>|description|canonical|og:' ``` You can also run the [Unhead CLI](/docs/typescript/head/guides/tooling/cli) over every generated page: ```bash 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.