If you're building on Nuxt or plain Vue, the good news is that structured data is better supported here than in almost any other framework, there's a dedicated, actively maintained module built specifically for it. The catch is that "well-supported" isn't the same as "automatic," and the setup looks meaningfully different depending on whether you're running Nuxt or a standalone Vue app. This guide covers both, plus the one rendering rule that determines whether any of it actually reaches Google or an AI crawler.
Here's the short version: in Nuxt, install nuxt-schema-org, set your identity once in nuxt.config.ts, and call useSchemaOrg() with typed define* helpers on each page, most of the boilerplate is inferred for you. In plain Vue (no Nuxt), there's no zero-config module, so you install @unhead/schema-org/vue directly and wire up the same composable yourself, page by page. Either way, your JSON-LD has to end up in the HTML your server actually sends, not something painted in after the page hydrates. We'll walk through exactly how to do each, correctly.
Why Nuxt handles this differently than Next.js or Astro
If you've set up schema in Next.js or Astro, you're used to hand-rolling a JSON-LD object, JSON.stringify-ing it, and injecting it into a <script> tag yourself, with the manual escaping that requires.
Vue's ecosystem took a different path. The Nuxt team maintains Unhead, the head-management library both Nuxt and standalone Vue apps use for <title>, meta tags, and, via a dedicated extension, Schema.org JSON-LD. That extension gives you useSchemaOrg(), a composable, plus a full set of typed define* helpers (defineArticle, defineProduct, defineOrganization, and so on) that build correctly-shaped, correctly-connected schema nodes for you. In Nuxt specifically, the nuxt-schema-org module wraps all of this into a near-zero-config setup: install it, tell it who you are once, and it infers most of the rest, your WebSite, your WebPage, your canonical URLs, from context.
This means the failure modes are different from React-based frameworks. You're less likely to write malformed JSON-LD by hand, because the helpers generate it for you. What you're more likely to get wrong is identity setup (not configuring who "you" are, so nothing connects), route-level omissions (forgetting per-page schema on dynamic routes), and, if you're on plain Vue without the Nuxt module, the SSR requirement, which doesn't go away just because a library is doing the serialization for you.
Nuxt: the zero-config path with nuxt-schema-org
Start here if you're on Nuxt. Install the module:
npx nuxi module add schema-org
Then set your site-wide identity in nuxt.config.ts, this is the single most important step, because every page-level schema node connects back to it:
// nuxt.config.ts
import { defineOrganization } from 'nuxt-schema-org/schema'
export default defineNuxtConfig({
modules: ['nuxt-schema-org'],
site: {
url: 'https://brightcoffee.example',
name: 'Bright Coffee Roasters',
},
schemaOrg: {
identity: defineOrganization({
name: 'Bright Coffee Roasters',
logo: '/logo.png',
sameAs: [
'https://www.linkedin.com/company/bright-coffee-roasters',
'https://www.instagram.com/brightcoffee',
],
}),
},
})That identity block is what everything else on your site references. Skip it, and you end up with disconnected Article and Product nodes that never resolve back to a recognized Organization, the same fragmentation problem that undermines a schema graph anywhere else.
With the module installed, Nuxt emits a sensible default, WebPage and WebSite, on every page automatically, with no code required. For most pages, that default is a fine baseline. For content-rich pages, you'll want to add specific types.
Adding page-level schema with useSchemaOrg()
On any page or component, useSchemaOrg() accepts an array of define* nodes. Here's a complete, well-formed example for a blog article page:
<!-- pages/blog/[slug].vue -->
<script lang="ts" setup>
import { defineArticle, defineBreadcrumb, useSchemaOrg } from '#imports'
const route = useRoute()
const { data: post } = await useAsyncData(`post-${route.params.slug}`, () =>
$fetch(`/api/posts/${route.params.slug}`)
)
useSchemaOrg([
defineArticle({
headline: () => post.value?.title,
description: () => post.value?.description,
image: () => post.value?.coverImage,
datePublished: () => post.value?.publishedAt,
dateModified: () => post.value?.updatedAt,
author: {
name: () => post.value?.author.name,
url: () => post.value?.author.url,
},
}),
defineBreadcrumb({
itemListElement: [
{ name: 'Home', item: '/' },
{ name: 'Blog', item: '/blog' },
{ name: () => post.value?.title, item: route.path },
],
}),
])
</script>A few things worth calling out. The values are passed as getters (() => post.value?.title) rather than raw strings, this is deliberate, because your data is often still resolving when the composable first runs, and Unhead re-evaluates getters reactively as your data updates. Passing a raw undefined string instead of a getter is a common source of missing fields on Nuxt schema. The Article node automatically connects to your site-wide Organization identity without you wiring an @id reference by hand, this is the module doing the connected-graph work that you'd otherwise do manually in Next.js or Astro. And breadcrumbs are a separate node, connected by context rather than nested inside the article.
Cutting boilerplate with templateParams
If you're not using the full nuxt-schema-org module, say, you're using @unhead/schema-org directly inside Nuxt for finer control, you can still remove most of the repetition by setting template parameters once, typically in a layout:
<!-- layouts/default.vue -->
<script lang="ts" setup>
const route = useRoute()
useHead({
templateParams: {
schemaOrg: {
host: 'https://brightcoffee.example',
path: route.path,
inLanguage: 'en',
},
},
})
useSchemaOrg([
defineWebPage(),
defineWebSite({ name: 'Bright Coffee Roasters' }),
defineOrganization({ name: 'Bright Coffee Roasters' }),
])
</script>Nuxt's hierarchical head system means a layout-level useSchemaOrg() call applies across every page beneath it, you don't need to repeat your WebSite and Organization nodes on every single route, only the page-specific ones like Article or Product.
Plain Vue: what Nuxt hides that you now have to do yourself
If you're not on Nuxt, a Vite-powered Vue SPA, or a custom Vue setup, there's no zero-config module doing this for you. You install the Vue-specific package directly:
npm install @unhead/schema-org
And wire it into your app entry point:
// main.ts
import { createApp } from 'vue'
import { createHead } from '@unhead/vue'
import { UnheadSchemaOrg } from '@unhead/schema-org/vue'
import App from './App.vue'
const app = createApp(App)
const head = createHead({
plugins: [UnheadSchemaOrg()],
})
app.use(head)
app.mount('#app')From there, the composable API in your components looks nearly identical to Nuxt:
<script lang="ts" setup>
import { defineArticle, useSchemaOrg } from '@unhead/schema-org/vue'
const article = await fetchArticle()
useSchemaOrg([
defineArticle({
headline: article.title,
image: article.image,
datePublished: article.publishedAt,
dateModified: article.updatedAt,
author: {
name: article.author.name,
url: article.author.url,
},
}),
])
</script>The difference isn't the composable, it's everything Nuxt was doing for you underneath it: automatic identity resolution, default WebPage/WebSite nodes, @id inference from your site config, and, the part that matters most, server rendering. In plain Vue, none of that comes for free, and the last one is where most standalone Vue schema implementations quietly fail.
The rule that decides whether any of this counts: server-rendered output
This is the same non-negotiable requirement that applies to Next.js and Astro, and it applies here with zero exceptions: your JSON-LD has to be present in the HTML your server sends, not something that only appears after client-side JavaScript runs.
A pure client-side-rendered Vue SPA (createApp mounting into an empty <div id="app">, no SSR) ships an essentially blank HTML document. Unhead correctly builds your schema in the browser, but by the time it does, the crawler that fetches your page has often already moved on, many crawlers, including AI crawlers, read the HTML as delivered and don't execute your JavaScript the way a browser does.
In Nuxt, this is handled by default. Universal rendering (SSR) is Nuxt's default mode, the server generates full HTML, including your Schema.org output, and the browser hydrates it afterward. Static site generation (prerendering) works the same way for schema purposes: the JSON-LD is baked into the static HTML at build time. Either mode ships your structured data in the initial response. The one setting to watch is ssr: false, which switches a route to pure client-side rendering, fine for an authenticated dashboard that doesn't need to be indexed, a real problem for any public page you want a crawler to read correctly.
In plain Vue, you don't get this for free. A standard Vite + Vue SPA is client-side rendered by default, full stop, which means your carefully-built useSchemaOrg() output may never reach a crawler unless you add server-side rendering yourself (via vite-ssr, vue-server-renderer, or a similar setup) or migrate the content-facing parts of your app to Nuxt, which handles this by default. If your Vue app is genuinely a client-only application, no content you need indexed, this doesn't matter. If it's a marketing site or blog you want found, it matters enormously, and it's worth checking before you invest more time in the schema itself.
The universal test, either way: view source, not the DOM inspector, on a live URL and confirm your <script type="application/ld+json"> block is actually there in the raw response. The dev tools "Elements" panel shows you the post-hydration DOM, which will always look correct; it tells you nothing about what a crawler received.
Do you need to worry about escaping?
In React, injecting JSON-LD manually via dangerouslySetInnerHTML means you're responsible for escaping characters like < yourself, or you risk both broken markup and a real XSS vector. It's a fair question whether Vue has the same trap.
When you use useSchemaOrg() and the define* helpers, Unhead handles serialization and escaping for you, this is one of the genuine advantages of using the library as intended rather than hand-rolling the tag. You don't need to manually escape strings passed into defineArticle() or similar.
The risk reappears if you bypass the library, for instance, injecting a raw <script type="application/ld+json"> tag yourself via v-html with a manually-stringified object, rather than going through useSchemaOrg(). If you do that, the same rule applies as anywhere else: escape < in string values before injecting, because an unescaped < inside a JSON-LD string can prematurely close the script tag and both break your markup and open an injection risk. The practical guidance is simple: there's rarely a good reason to hand-roll this in Vue when the typed helpers exist. Use them, and this entire category of bug goes away.
Don't hardcode what should be generated
A pattern worth naming even though it's not Vue-specific: it's tempting to write your useSchemaOrg() calls once with static values and move on. Resist that for anything backed by real content that changes, prices, review counts, event dates, availability. Hardcoded schema drifts out of sync with your actual page the moment that content updates, and structured data that contradicts what's visible on the page is worse than no structured data at all. Drive your schema calls from the same data your template renders from (as the examples above do, pulling from post.value or a fetched article), so the two can't silently disagree.
A complete connected example
Here's what a fuller page looks like once identity, page-level schema, and breadcrumbs are all wired together, this is the shape a product page in a Nuxt storefront would produce:
<script lang="ts" setup>
import { defineBreadcrumb, defineProduct, useSchemaOrg } from '#imports'
const { data: product } = await useFetch('/api/products/ethiopian-beans')
useSchemaOrg([
defineProduct({
name: () => product.value?.name,
description: () => product.value?.description,
image: () => product.value?.image,
brand: { name: 'Bright Coffee Roasters' },
offers: {
price: () => product.value?.price,
priceCurrency: 'USD',
availability: () => product.value?.inStock ? 'InStock' : 'OutOfStock',
},
aggregateRating: () => product.value?.reviewCount ? {
ratingValue: product.value.rating,
reviewCount: product.value.reviewCount,
} : undefined,
}),
defineBreadcrumb({
itemListElement: [
{ name: 'Home', item: '/' },
{ name: 'Shop', item: '/shop' },
{ name: () => product.value?.name, item: `/shop/${product.value?.slug}` },
],
}),
])
</script>Notice aggregateRating is only included when reviewCount genuinely exists, this matters for the same reason it matters everywhere else: Google requires your rating markup to match what's actually visible on the page, and a Product page reviewing itself is treated differently from a business marking up reviews about itself, which isn't eligible for the star snippet at all.
Common mistakes to avoid
Skipping the site-wide identity in nuxt.config.ts. Without it, your page-level nodes have nothing authoritative to connect back to, and you end up with disconnected schema instead of a graph.
Passing raw values instead of getters when data is still loading. title: post.value?.title evaluated too early can bake in undefined. Use a getter (() => post.value?.title) so Unhead re-evaluates it as your data resolves.
Assuming plain Vue gets the same defaults as Nuxt. No automatic WebPage/WebSite nodes, no automatic identity resolution, no SSR by default, all of it has to be set up deliberately in a standalone Vue app.
Client-side-only rendering on content pages. ssr: false in Nuxt, or a non-SSR Vite Vue SPA, means your JSON-LD may never reach a crawler. Reserve client-only rendering for pages you don't need indexed.
Hand-rolling the <script> tag instead of using useSchemaOrg(). You lose automatic escaping and graph connection for no real benefit, the typed helpers exist precisely so you don't have to do this.
Hardcoding values that should be dynamic. Schema for prices, ratings, and availability needs to be driven by the same live data your page renders, or it drifts out of sync.
Forgetting per-route schema on dynamic pages. A default WebPage/WebSite covers the baseline; individual product, article, or event pages still need their own specific node.
Where AI Schema Gen fits in a Nuxt or Vue build
Everything above gets you correct, well-connected schema on the pages you write it for. The harder problem in any decoupled architecture, Nuxt included, is the same one that shows up in headless WordPress and headless Shopify builds: your schema is only as good as the person maintaining it stays disciplined about updating it as content changes, and across dozens or hundreds of routes, that discipline erodes.
AI Schema Gen approaches this from the content side rather than the framework side: it generates schema from your actual page content and keeps it aligned as that content changes, rather than relying on someone remembering to update a useSchemaOrg() call every time a price, an event date, or a review count moves. In a Nuxt or Vue build fed by a headless CMS, this means the schema layer stays accurate to what's actually on the page without becoming another piece of frontend code someone has to maintain by hand, the same drift problem that quietly breaks schema in headless WordPress and headless Shopify migrations shows up here too, and the fix is the same: generate from content, don't hand-maintain templates.
It's also worth thinking about this at the entity level, not just the page level. Nuxt's identity config gives you a connected on-site graph, your Organization, your pages, your products, cross-referenced by @id. That's real and valuable. What it doesn't do on its own is build the external validation and topical-authority signals, your sameAs network, Wikidata connections, disambiguated knowsAbout mapping, that make your brand a recognized entity beyond your own site's graph. That's the layer covered in our entity profile guide, and it's complementary to whatever schema your framework is already emitting, not a replacement for it.
Validating your Nuxt or Vue schema
View source on the live, deployed URL, not localhost, not the dev-tools DOM inspector, and confirm your JSON-LD block is present in the raw HTML. This is the single check that catches SSR misconfiguration before anything else does.
Run key page templates through Google's Rich Results Test, one per template (article, product, event), since a misconfiguration usually applies to an entire route pattern rather than one page.
Use the Schema Markup Validator at validator.schema.org for broader schema.org validity beyond just rich-result-eligible types.
Check Search Console after deploying, and watch for structured-data parsing errors across your site as Google recrawls, this is where template-wide issues surface after the fact.
Frequently Asked Questions
Generate perfect schema in 30 seconds
AI Schema Gen handles everything automatically, free to start.
Get Started Free