Best practices for schema markup in Vue apps
Problem Statement
Client-side rendered (CSR) Vue applications frequently experience structured data drop-off during initial Googlebot crawls due to JavaScript execution queue delays. When Vue hydrates the DOM, mismatched server/client states can overwrite or duplicate <script type="application/ld+json"> nodes, causing schema.org validation failures. Router transitions without explicit cleanup routines compound this by appending new JSON-LD blocks while leaving stale nodes in the <head>, triggering duplicate entity warnings in Search Console. Aligning injection logic with established Dynamic Metadata and Structured Data Management frameworks prevents state desynchronization and ensures consistent rich snippet eligibility across route changes.
Step-by-Step Fix
-
Isolate Schema Injection to Component Lifecycle Bind JSON-LD creation to
onMountedand removal toonBeforeUnmount. This guarantees schema nodes only exist when their corresponding route is active, preventing DOM pollution. Crawl Impact: Eliminates duplicate schema warnings that trigger Google’s structured data parser to ignore the entire block. -
Implement Router-Aware Cleanup Attach a global
router.afterEachguard that queriesdocument.querySelectorAll('script[type="application/ld+json"]')and removes nodes not matching the current route’s expected schema ID. This acts as a safety net for edge-case navigation (e.g., direct URL entry, back/forward cache). Crawl Impact: Ensures Googlebot’s rendering engine sees exactly one valid JSON-LD payload per URL, preserving entity mapping accuracy. -
Defer Injection Until Async Data Resolves Never inject placeholder or empty JSON-LD. Wait for API responses (e.g., product details, article metadata) before serializing and appending the script. Use Vue
watchorcomputedproperties to trigger injection only when the payload is complete. Crawl Impact: Preventsnullorundefinedvalues from being indexed, which can cause rich result disqualification or partial snippet rendering. -
Serialize Strictly and Sanitize Use
JSON.stringify()with a replacer function to strip non-serializable values. Never inject raw template strings into the DOM to avoid XSS vulnerabilities and malformed JSON syntax. Crawl Impact: Guarantees parseable JSON-LD that passes Google’s strict syntax validation, directly influencing indexing confidence.
Validation
Execute the following verification pipeline before and after deployment:
- Rendered DOM Inspection
- Use Chrome DevTools → Elements →
<head>to verify exactly one<script type="application/ld+json">exists per route. - Confirm
textContentmatches expected schema.org type (e.g.,Product,Article,WebPage).
- Google Rich Results Test & Schema Markup Validator
- Paste the live URL. Validate that all required properties (
@context,@type,name,description, etc.) are populated. - Check for “Missing field” or “Invalid value” warnings. Zero warnings required for production.
- Search Console URL Inspection API
- Request indexing for the target URL. Review the “Rendered HTML” tab to confirm JSON-LD appears in the final DOM snapshot.
- Monitor indexing latency: CSR-injected schema typically takes 1–7 days to process. Pre-rendered or server-injected schema processes within hours.
- Automated CI/CD Validation
- Integrate
jest-domor@testing-library/vueto assert schema presence in component tests. - Run
schema-dtsorajvagainst generated JSON-LD payloads in your build pipeline to catch type mismatches before merge.
Code/Config
// composables/useSchema.ts
import { onMounted, onBeforeUnmount, watch, ref } from 'vue'
import type { RouteLocationNormalized } from 'vue-router'
export function useSchema(schemaData: Ref<Record<string, any> | null>, route: RouteLocationNormalized) {
const scriptId = `json-ld-${route.path.replace(/\//g, '-')}`
let scriptEl: HTMLScriptElement | null = null
const injectSchema = () => {
if (!schemaData.value) return
// Remove existing node if present
const existing = document.getElementById(scriptId)
if (existing) existing.remove()
scriptEl = document.createElement('script')
scriptEl.type = 'application/ld+json'
scriptEl.id = scriptId
scriptEl.textContent = JSON.stringify(schemaData.value)
document.head.appendChild(scriptEl)
}
const cleanupSchema = () => {
const existing = document.getElementById(scriptId)
if (existing) existing.remove()
scriptEl = null
}
onMounted(injectSchema)
onBeforeUnmount(cleanupSchema)
// Re-inject if async data resolves after mount
watch(schemaData, (newVal) => {
if (newVal) injectSchema()
}, { immediate: false })
return { cleanupSchema }
}
// router/index.ts (Global Safety Net)
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [/* ... */]
})
router.afterEach(() => {
// Fallback cleanup for orphaned nodes during rapid navigation
const allLdJson = document.querySelectorAll('script[type="application/ld+json"]')
allLdJson.forEach((el) => {
if (!el.id || !el.id.startsWith('json-ld-')) {
el.remove()
}
})
})
export default router
<!-- components/ProductView.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { useSchema } from '@/composables/useSchema'
const route = useRoute()
const productData = ref<Record<string, any> | null>(null)
onMounted(async () => {
// Simulate async fetch
productData.value = await fetch(`/api/products/${route.params.id}`).then(r => r.json())
})
useSchema(productData, route)
</script>
FAQ
How do I prevent duplicate JSON-LD nodes during Vue router navigation?
Implement an onBeforeUnmount cleanup function that queries and removes existing script[type="application/ld+json"] elements before injecting new route-specific schema. Pair this with a global router.afterEach guard to catch orphaned nodes from interrupted fetches.
Does client-side schema injection affect Google indexing speed? Yes. JS-rendered schema relies on Googlebot’s rendering queue, typically causing 1–7 day delays. Pre-rendering via SSR/SSG or using dynamic rendering ensures immediate discovery and faster rich result eligibility.
How do I handle hydration mismatches when injecting schema in Vue 3?
Ensure server-rendered schema exactly matches the initial client state, or defer all JSON-LD injection to client-side lifecycle hooks (onMounted) to bypass hydration checks entirely. Never render JSON-LD in the SSR payload if the client will immediately overwrite it.
What validation tools verify Vue-injected JSON-LD before deployment?
Use Google Rich Results Test, Schema.org Validator, and automated headless browser checks (Puppeteer/Playwright) to parse the rendered DOM and validate JSON syntax against schema.org types. Integrate ajv or schema-dts in CI pipelines for pre-deployment type safety.