If you can't see meta tags in View Source, neither can Google. Most Vue SEO bugs come from client-side rendering, hydration mismatches, or Unhead configuration mistakes.
Two different ways to view HTML show different content:
View Source (Right-click → View Page Source) shows the initial HTML your server sends. This is what Google's crawler sees first.
Inspect Element (F12 → Elements tab) shows the live DOM after JavaScript executes. Meta tags added by JavaScript appear here but might not be indexed.
Test: Open your site, right-click, select "View Page Source." Search for <title> or <meta name="description". If you can't find your content in View Source, Google can't either.
<!-- ❌ BAD: Empty in View Source (SPA without SSR) -->
<!DOCTYPE html>
<html>
<head>
<title>Loading...</title>
</head>
<body>
<div id="app"></div>
<script src="/app.js"></script>
</body>
</html>
<!-- ✅ GOOD: Content in View Source (SSR) -->
<!DOCTYPE html>
<html>
<head>
<title>How to Debug Vue SEO | MySite</title>
<meta name="description" content="Fix meta tags not rendering in Vue apps...">
</head>
<body>
<div id="app">
<h1>How to Debug Vue SEO</h1>
<p>Fix meta tags not rendering...</p>
</div>
<script src="/app.js"></script>
</body>
</html>
Google renders JavaScript but it's a two-wave process: crawl the HTML first, then render JavaScript days later. If critical content only appears after JavaScript executes, indexing gets delayed by weeks.
Single Page Applications change routes without full page reloads. Meta tags set during initial render might not update when users navigate.
Common mistake: Using document.title instead of Unhead.
<!-- ❌ Doesn't work on navigation -->
<script setup>
import { watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
watch(() => route.path, () => {
document.title = route.meta.title // Won't update in head manager
})
</script>
Fix: Use useHead() from Unhead. It tracks navigation and updates meta tags correctly.
<!-- ✅ Updates on every navigation -->
<script setup>
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
const route = useRoute()
useHead({
title: () => route.meta.title as string
})
</script>
Unhead synchronizes meta tags across SSR and client-side navigation. The Unhead documentation explains reactivity patterns.
Open Chrome DevTools while navigating between pages. Watch the <head> section in Elements tab:
<head> elementIf meta tags don't change, you're not using useHead() or reactive values correctly.
Hydration is when Vue takes server-rendered HTML and "activates" it with reactivity and event listeners. A hydration mismatch occurs when the HTML rendered on the client differs from the server-rendered HTML.
Vue displays warnings like "Hydration completed but contains mismatches" or "Text content does not match server-rendered HTML." These warnings appear in browser console and indicate server/client HTML differences (Vue SSR Guide).
Invalid HTML nesting triggers browser auto-correction, causing mismatches:
<!-- ❌ Invalid: div inside p -->
<template>
<p /><div>Content</div> <!-- Browser auto-closes <p> before <div> -->
</template>
<!-- ✅ Valid HTML structure -->
<template>
<div>
<p>Content</p>
</div>
</template>
Browser-only APIs don't exist during SSR:
<!-- ❌ Crashes during SSR -->
<script setup>
</script>
<!-- ✅ Only access on client -->
<script setup>
import { onMounted, ref } from 'vue'
const userAgent = window.navigator.userAgent
const userAgent = ref('')
onMounted(() => {
userAgent.value = window.navigator.userAgent
})
</script>
Random values and timestamps differ between server and client:
<!-- ❌ Different on server vs client -->
<script setup>
</script>
<!-- ✅ Use v-if + onMounted -->
<script setup>
import { onMounted, ref } from 'vue'
const randomId = Math.random()
const timestamp = new Date().toLocaleString()
const randomId = ref('')
const mounted = ref(false)
onMounted(() => {
mounted.value = true
randomId.value = Math.random().toString()
})
</script>
<template>
<div v-if="mounted">
ID: {{ randomId }}
</div>
</template>
Third-party libraries without SSR support cause mismatches (How to Fix Vue Hydration Mismatch):
<!-- Wrap client-only libraries -->
<template>
<ClientOnly>
<GoogleMap />
</ClientOnly>
</template>
For inevitable mismatches (like timestamps), use data-allow-mismatch:
<template>
<time :data-allow-mismatch="true">
{{ new Date().toLocaleString() }}
</time>
</template>
This tells Vue to expect differences and skip warnings.
HTML minification causes hydration mismatches. Most HTML minifiers must be disabled (Harlan Wilton - Nuxt 3 Hydration Mismatch).
Cloudflare users: Disable Cloudflare's automatic HTML minifier in dashboard → Speed → Optimization → Auto Minify.
Browser cache loads outdated JavaScript. Clear cache when debugging mismatches.
Most Unhead bugs come from setup errors or missing SSR support.
Unhead requires initialization. Without it, useHead() silently fails.
// ❌ Missing Unhead setup
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
app.mount('#app')
Fix: Call createHead() and install plugin (Unhead Installation Guide):
import { createHead } from '@unhead/vue/client'
// ✅ Correct Unhead setup
import { createApp } from 'vue'
import App from './App.vue'
const app = createApp(App)
const head = createHead()
app.use(head)
app.mount('#app')
For SSR apps, import from @unhead/vue/server instead.
Unhead v2 changed import paths. Old imports fail silently or throw errors.
// ❌ Old Unhead v1 imports
import { createHead } from '@unhead/vue'
// ✅ Unhead v2 subpath imports
import { createHead } from '@unhead/vue/client' // Client-side
import { createHead } from '@unhead/vue/server' // Server-side
Check Unhead v2 migration guide for breaking changes.
Client-only Unhead won't help SEO. Meta tags appear in DevTools but not View Source.
// ❌ Client-only setup (no SEO benefit)
import { createHead } from '@unhead/vue/client'
// ✅ SSR setup (meta tags in initial HTML)
import { createHead } from '@unhead/vue/server'
Without SSR, search engines see empty <div id="app"></div> containers (vue-meta GitHub issue #610).
Passing .value instead of the ref breaks reactivity:
<script setup>
import { useHead } from '@unhead/vue'
import { ref } from 'vue'
const pageTitle = ref('Loading...')
// ❌ Loses reactivity
useHead({
title: pageTitle.value
})
// ✅ Stays reactive
useHead({
title: pageTitle
})
// Later update works with reactive version
pageTitle.value = 'Loaded Content'
</script>
For computed values, use getter functions:
<script setup>
import { useHead } from '@unhead/vue'
import { computed, ref } from 'vue'
const post = ref({ title: 'Loading...' })
useHead({
title: () => post.value.title // Updates when post changes
})
</script>
Multiple useHead() calls with same meta tags create duplicates:
<!-- ❌ Creates 2 description tags -->
<script setup>
import { useHead } from '@unhead/vue'
useHead({
meta: [{ name: 'description', content: 'First description' }]
})
useHead({
meta: [{ name: 'description', content: 'Second description' }]
})
</script>
Unhead deduplicates by default but multiple calls can override unexpectedly. Consolidate meta tags into single useHead() call per component.
DevTools helps debug meta tags, rendering issues, and JavaScript errors.
Network panel shows server-sent HTML before JavaScript executes:
Look for meta tags in this response. If missing, your SSR isn't working.
Elements panel lets you find all meta tags quickly:
<meta name="description"Compare what you find against your useHead() calls.
JavaScript errors break meta tag updates. Console shows Vue warnings and Unhead errors.
Look for:
Fix errors before debugging meta tags. Broken JavaScript breaks everything.
Lighthouse tab includes basic SEO checks:
Checks:
<title> element<html> has lang attributeLighthouse doesn't verify correctness—just presence. A title "undefined" passes but isn't useful.
URL Inspection tool shows exactly what Google sees when crawling your page.
Tool shows (Google URL Inspection documentation):
"URL is on Google" means the URL is eligible to appear in search results (not guaranteed). "URL is not on Google" means the URL can't appear—check "Page indexing" section for why.
See how Google renders your page:
Compare screenshot to your actual page. Missing content indicates rendering problems.
Google renders JavaScript but processes happen in two waves:
If content only appears after JavaScript, it gets delayed. URL Inspection shows both:
After fixing issues:
Google doesn't guarantee indexing—it still evaluates content quality.
Daily quota limits requests. For bulk operations, use Google Indexing API (up to 2,000 URLs/day).
Cause: Client-side rendering without SSR.
Test:
curl -s https://yoursite.com | grep "<title>"
If curl shows empty title, you're not using SSR.
Fix: Implement SSR using Vite SSR or a framework like Nuxt. See Vue rendering modes guide.
Cause: Not using Unhead, or using document.title directly.
Test: Navigate between pages, watch <title> in DevTools Elements tab.
Fix:
<script setup>
import { useHead } from '@unhead/vue'
import { useRoute } from 'vue-router'
const route = useRoute()
useHead({
title: () => route.meta.title as string
})
</script>
Cause: Google rewrites 60-70% of meta descriptions and titles it deems low-quality.
Test: Search for site:yoursite.com in Google. Compare displayed titles to your actual meta tags.
Fix: Write better titles and descriptions:
Google still might rewrite—that's normal. If it rewrites everything, your meta tags are probably too generic or off-topic.
Cause: Server and client render different meta tag values (timestamps, random IDs, browser APIs).
Test: Look for console warnings: "Hydration completed but contains mismatches."
Fix: Use ClientOnly wrapper or data-allow-mismatch:
<script setup>
import { onMounted, ref } from 'vue'
const timestamp = ref('')
onMounted(() => {
timestamp.value = new Date().toISOString()
})
useHead({
meta: [
{ property: 'og:updated_time', content: timestamp }
]
})
</script>
Cause: Data fetched in onMounted() instead of during SSR.
Test: View Source shows "Loading..." instead of actual content.
Fix: Fetch data before render:
<!-- ❌ Client-only fetch -->
<script setup>
import { onMounted, ref } from 'vue'
</script>
<!-- ✅ SSR-compatible fetch -->
<script setup>
const post = ref({ title: 'Loading...' })
onMounted(async () => {
post.value = await fetch('/api/post').then(r => r.json())
})
useHead({ title: () => post.value.title })
const post = ref(await fetch('/api/post').then(r => r.json()))
useHead({ title: () => post.value.title })
</script>
For client-only apps, consider prerendering critical pages.
Don't wait for Google to tell you something's broken. Test locally:
Right-click → View Page Source. Search for critical meta tags. If they're missing, fix SSR before deploying.
# Check initial HTML
curl -s https://yoursite.com | grep -A 5 "<head>"
# Check specific meta tag
curl -s https://yoursite.com | grep 'name="description"'
Should show full meta tags in output.
Set up staging site in Search Console. Test URL Inspection on staging before pushing to production.
Automate SEO checks in CI/CD:
npm install -g @lhci/cli
# Run Lighthouse
lhci autorun --collect.url=http://localhost:3000
Configure assertion for minimum SEO score.
Nuxt handles SSR and meta tag management automatically. Most debugging issues disappear with Nuxt's built-in SEO features. See Nuxt SEO debugging guide for framework-specific solutions.