---
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."
canonical_url: "https://unhead.unjs.io/docs/head/guides/get-started/single-page-applications"
last_updated: "2026-07-28T14:47:57.734Z"
---

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]
<!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:

```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 '<title>|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.
