How to Set Page Titles in Vue

Set dynamic page titles in Vue 3 with useHead. Learn title templates, reactive titles, and SSR patterns that Google indexes correctly.
Harlan WiltonHarlan Wilton8 mins read Published Updated
What you'll learn
  • Use useHead() instead of document.title for SSR compatibility
  • Add a title template to append your site name consistently
  • Keep titles under 60 characters to avoid truncation
  • Set og:title separately for social sharing

Page titles appear in browser tabs and as the clickable headline in search results. Google uses your <title> tag over 80% of the time when generating title links—though studies show it rewrites 61-76% of titles to some degree.

<head>
  <title>Mastering Titles in Vue · Vue SEO</title>
</head>

Setting titles in Vue 3 requires a head manager like Unhead since document.title won't work during server-side rendering.

Quick Reference

// Basic title
useHead({ title: 'Home' })

// With template (adds site name)
useHead({
  title: 'Home',
  titleTemplate: '%s | MySite'
})

// Reactive title from data
const post = ref({ title: 'Loading...' })
useHead({
  title: () => post.value.title
})

// SEO-focused (includes og:title)
useSeoMeta({
  title: 'Home',
  ogTitle: 'Home | MySite'
})

Why Not document.title?

You might try setting titles directly:

// ❌ Breaks SSR, may not be indexed
document.title = 'Home'

This fails during server-side rendering—the title won't exist in the initial HTML response. Search engines render JavaScript but may not wait for client-side updates.

Using document.title in a Vue SPA means search engines may index your pages with missing or incorrect titles. Always use a head manager like Unhead for SEO-critical metadata.

Use Unhead instead. It handles both SSR and client-side updates. For Google's official guidance, see Influencing your title links.

Setting Titles with useHead()

The useHead() composable sets titles that work in SSR and client-side navigation:

input.vue
<script setup lang="ts">
useHead({
  title: 'Home'
})
</script>
output.html
<head>
  <title>Home</title>
</head>

Works in any component. You can set other head tags in the same call:

<script setup lang="ts">
useHead({
  title: 'Home',
  meta: [
    { name: 'description', content: 'Welcome to MyApp' }
  ]
})
</script>

Reactive Titles

Unhead accepts refs, reactive objects, and computed values. Pass the reactive reference directly—don't destructure it with .value.
useHead({
  title: myTitle.value // ❌ Loses reactivity
})

useHead({
  title: myTitle // ✅ Stays reactive
})

Computed getter syntax works for derived titles:

const post = ref({ title: 'Loading...' })

useHead({
  title: () => post.value.title // Updates when post changes
})

SSR and SEO

Fetch data before render, not in onMounted(). Client-only fetches mean search engines see your loading state:

<script setup lang="ts">
const postTitle = ref('Loading...')
useHead({ title: postTitle })

// ❌ onMounted runs after SSR—Google sees "Loading..."
onMounted(async () => {
  postTitle.value = (await fetchPostData()).title
})
</script>

Use SSR-compatible data fetching (Vue's onServerPrefetch() or framework solutions like Nuxt's useFetch()).

Title Templates

Most sites append a site name to titles for brand recognition. Google recommends adding your site name with a delimiter:

<head>
  <title>Home | MySite</title>
</head>

Use titleTemplate with a title template:

input.vue
<script setup lang="ts">
useHead({
  title: 'Home',
  titleTemplate: '%s | MySite'
})
</script>
output.html
<head>
  <title>Home | MySite</title>
</head>

The %s token gets replaced with your page title (or empty string if none set).

Disabling the Template

Override the template for specific pages by passing null:

input.vue
<script lang="ts" setup>
useHead({
  title: 'Home',
  titleTemplate: null
})
</script>
output.html
<head>
  <title>Home</title>
</head>

Template Params

For dynamic site names or separators, use the Template Params plugin:

import { TemplateParamsPlugin } from '@unhead/vue'

const head = injectHead()
head.use(TemplateParamsPlugin)

Then use custom params:

input.vue
<script setup lang="ts">
useHead({
  title: 'Home',
  titleTemplate: '%s %separator %siteName',
  templateParams: {
    separator: '·',
    siteName: 'MySite'
  }
})
</script>
output.html
<head>
  <title>Home · MySite</title>
</head>

Common separators: | - ·

Template params work in meta tags too:

useHead({
  templateParams: { siteName: 'MyApp' },
  title: 'Home',
  meta: [
    { name: 'description', content: 'Welcome to %siteName' },
    { property: 'og:title', content: 'Home | %siteName' }
  ]
})

Social Share Titles

Social platforms use og:title and twitter:title meta tags. Use useSeoMeta() to set these:

Nuxt X Share
Nuxt X Share
input.vue
<script setup lang="ts">
useSeoMeta({
  title: 'Why you should eat more broccoli',
  titleTemplate: '%s | Health Tips',
  // og:title doesn't use titleTemplate—set it explicitly
  ogTitle: 'Health Tips: 10 reasons to eat more broccoli',
  // twitter:title only needed if different from og:title
  twitterTitle: 'Hey X! 10 reasons to eat more broccoli',
})
</script>
output.html
<head>
  <title>Why you should eat more broccoli | Health Tips</title>
  <meta property="og:title" content="Health Tips: 10 reasons to eat more broccoli" />
  <meta name="twitter:title" content="Hey X! 10 reasons to eat more broccoli" />
</head>

Twitter/X falls back to og:title if twitter:title isn't set.

Title Length

Google displays roughly 50-60 characters before truncating. Studies show titles between 51-60 characters have the lowest rewrite rates (39-42%).

Longer titles still get indexed—Google just truncates the display. Front-load important keywords since users may only see the first 50 characters.

Vue Router Integration

Set titles from route meta for centralized title management:

// router.ts
const routes = [
  { path: '/', component: Home, meta: { title: 'Home' } },
  { path: '/about', component: About, meta: { title: 'About Us' } },
  { path: '/blog/:slug', component: BlogPost, meta: { title: 'Blog' } }
]

Use a navigation guard to apply titles on route change:

router.afterEach((to) => {
  const title = to.meta.title as string
  if (title) {
    useHead({ title })
  }
})

For dynamic routes, override in the component:

<script setup lang="ts">
// Fetched post data overrides route meta
const post = await fetchPost(route.params.slug)

useHead({
  title: post.title // "How to Build a Blog" instead of generic "Blog"
})
</script>

Set a global title template in your guard:

router.afterEach((to) => {
  useHead({
    title: to.meta.title as string || 'MySite',
    titleTemplate: '%s | MySite'
  })
})

Handle nested routes by walking matched routes:

router.afterEach((to) => {
  // Find deepest route with a title
  const title = [...to.matched]
    .reverse()
    .find(r => r.meta.title)
    ?.meta
    .title as string

  if (title)
    useHead({ title })
})

Using Nuxt? Check out Nuxt SEO which handles much of this automatically. Learn more about Page Titles in Nuxt →

Test Your Knowledge

Quick Check

Which approach correctly sets a reactive title that updates when data changes?

  • useHead({ title: myTitle.value }) - Loses reactivity
  • useHead({ title: myTitle }) - Correct! Stays reactive
  • document.title = myTitle.value - Breaks SSR
  • useHead({ title: () => myTitle }) - Unnecessary wrapper

Checklist

Checklist
  • Use useHead() or useSeoMeta() instead of document.title
  • Set up a title template with your site name
  • Keep titles under 60 characters
  • Set og:title for social sharing
  • Fetch data server-side for dynamic titles
  • Test titles render correctly in SSR