X Enterprises
Composables

Schema.org JSON-LD

Schema composables — emit Product, Review, FAQPage, BreadcrumbList, Organization, Person, WebSite, and Article/BlogPosting JSON-LD for Google rich results. Drive star SERPs and featured snippets.

Schema.org JSON-LD

The useSchema composable is the SEO foundation for every review site. Each export auto-injects a <script type="application/ld+json"> block into the page head — no manual template wiring needed.

Available composables

ComposableEmitsDrives
useOrganizationSchema()OrganizationKnowledge Panel site link
usePersonSchema()PersonE-E-A-T author signal
useProductSchema()Product + Review + AggregateRating + OfferStar SERPs (the #1 ROI block)
useFAQSchema()FAQPageFAQ rich results in SERPs
useBreadcrumbSchema()BreadcrumbListBreadcrumb SERPs
useWebSiteSchema({ searchUrlTemplate })WebSite (+ optional SearchAction)Sitelinks searchbox
useArticleSchema({ type: 'BlogPosting', ... })BlogPosting / Article / NewsArticleArticle rich results
useSiteMeta()<title>, <meta>, OG, Twitter Card, canonical, hreflang, robotsStandard meta tags
<script setup>
const route = useRoute()

// 1) Page meta (title, description, OG, Twitter, canonical, robots)
useSiteMeta({
  title: 'Logitech K1 Wireless Keyboard Review',
  description: 'After 3 months of daily testing, the K1 is our top pick for typists.',
  image: '/reviews/k1/og.jpg',
  type: 'article',
  publishedTime: '2026-01-15',
  modifiedTime: '2026-04-20',
  author: 'Jane Reviewer',
  section: 'Reviews',
  tags: ['wireless keyboards', 'mechanical', 'logitech'],
})

// 2) Article schema (BlogPosting — the article side of the review)
useArticleSchema({
  type: 'BlogPosting',
  headline: 'Logitech K1 Wireless Keyboard Review',
  description: 'After 3 months of daily testing, the K1 is our top pick for typists.',
  image: ['/reviews/k1/hero.jpg', '/reviews/k1/side.jpg'],
  datePublished: '2026-01-15',
  dateModified: '2026-04-20',
  articleSection: 'Reviews',
  keywords: ['wireless keyboards', 'mechanical', 'logitech'],
  wordCount: 1850,
  articleBody: 'After 3 months of daily use...',
  inLanguage: 'en-US',
})

// 3) Product + Review + AggregateRating + Offer (the product side)
useProductSchema({
  product: {
    name: 'Logitech K1',
    description: 'Compact mechanical wireless keyboard with hot-swappable switches',
    image: ['/reviews/k1/hero.jpg', '/reviews/k1/side.jpg'],
    brand: 'Logitech',
    sku: 'K1-001',
  },
  ratingValue: 4.6,
  ratingCount: 1234,
  reviewCount: 89,
  offer: {
    price: 79.99,
    priceCurrency: 'USD',
    availability: 'InStock',
    priceValidUntil: '2026-12-31',
  },
  reviewBody: 'After 3 months of daily use...',
  datePublished: '2026-01-15',
})

// 4) Breadcrumbs
useBreadcrumbSchema({
  items: [
    { label: 'Home', href: '/' },
    { label: 'Reviews', href: '/reviews' },
    { label: 'Wireless Keyboards', href: '/reviews/wireless-keyboards' },
    { label: 'Logitech K1' },
  ],
})

// 5) FAQ
useFAQSchema({
  faqs: [
    { question: 'Is the K1 hot-swappable?', answer: 'Yes — all 87 switches are hot-swappable with 3-pin and 5-pin support.' },
    { question: 'Battery life?', answer: 'Up to 200 hours with backlight off; ~40 hours with backlight on full.' },
  ],
})
</script>

This is the dual-block pattern Google recommends for review content: one BlogPosting JSON-LD for the article side, one Product + Review + AggregateRating + Offer JSON-LD for the product side. Each composable handles its own block — you just call both.

Pure blog posts (no product) need only the article side. The dual-block is overkill — just useArticleSchema() + useSiteMeta() + useBreadcrumbSchema():

<script setup>
const route = useRoute()
const article = await useArticle(route.params.slug)

useSiteMeta({
  title: article.title,
  description: article.excerpt,
  image: article.heroImage,
  type: 'article',
  publishedTime: article.publishedAt,
  modifiedTime: article.updatedAt,
  author: article.author.name,
  section: article.category,
  tags: article.tags,
  keywords: article.seoKeywords,           // separate list for <meta name="keywords">
  alternate: article.translations?.map(t => ({
    hreflang: t.lang,
    href: t.url,
  })),
})

useArticleSchema({
  type: 'BlogPosting',
  headline: article.title,
  description: article.excerpt,
  image: article.heroImage,
  datePublished: article.publishedAt,
  dateModified: article.updatedAt,
  articleSection: article.category,
  keywords: article.tags,
  wordCount: article.wordCount,
  articleBody: article.bodyText,
  inLanguage: 'en-US',
})

useBreadcrumbSchema({
  items: [
    { label: 'Home', href: '/' },
    { label: article.category, href: `/category/${article.categorySlug}` },
    { label: article.title },
  ],
})
</script>

This replaces the ~80-line hand-rolled BlogPosting JSON-LD block that most consuming sites still ship on articles/[slug].vue.

Site-wide setup (in app.vue or a layout)

<script setup>
// Fire once per app — site identity + searchbox
useOrganizationSchema()
usePersonSchema()           // uses xAffiliate.author
useWebSiteSchema({
  searchUrlTemplate: 'https://example.com/search?q={search_term_string}',
})
</script>

useArticleSchema() API

FieldTypeDefaultDescription
headlinestringrequiredArticle headline. Drives <h1> in rich results.
type"Article" | "BlogPosting" | "NewsArticle""Article"Schema.org @type. Use "BlogPosting" for /articles/[slug].vue pages. "NewsArticle" for newsroom content.
descriptionstringShort description / excerpt.
imagestring | string[]Hero image (or array of images).
datePublishedstringrequiredISO 8601 publish date.
dateModifiedstringISO 8601 last-modified date.
urlstringroute pathCanonical article URL.
author{ name: string; url?: string }xAffiliate.authorAuthor. Defaults to site-wide author if not provided.
publisherNamestringxAffiliate.brand.namePublisher name.
publisherLogostringxAffiliate.brand.logoPublisher logo URL.
articleSectionstringSection / category (e.g. "Reviews"). Drives articleSection.
keywordsstring[]Tags / keywords. Joined with commas in the output.
wordCountnumberApproximate word count. Drives Google's "x min read" annotation.
articleBodystringArticle body (plain text). Used by Google's article parser.
inLanguagestringxAffiliate.brand.locale or "en_US"BCP-47 language tag.
isAccessibleForFreebooleantrueSubscribeWithGoogle signal.

useProductSchema() API

FieldTypeRequiredDescription
product.namestringProduct / review target name.
product.descriptionstringProduct description.
product.imagestring | string[]Image URL or array.
product.brandstringBrand name. Renders as Brand schema.
product.sku / product.mpnstringIdentifiers.
product.urlstringCanonical URL. Defaults to current route path.
ratingValuenumberNumeric rating. Clamped to [0, bestRating].
ratingCountnumberTotal ratings count. Required by Google for star SERPs.
reviewCountnumberSeparate review count (subset of ratingCount).
bestRatingnumber5Best possible rating.
offer.pricenumberOffer price for Offer schema.
offer.priceCurrencystring'USD'ISO 4217.
offer.availabilitystring'InStock'InStock / OutOfStock / PreOrder / Discontinued.
offer.priceValidUntilstringISO date.
reviewBodystringReview snippet shown in SERPs.
datePublished / dateModifiedstringISO 8601 dates.

useSiteMeta() API

FieldTypeDefaultDescription
titlestringsite namePage title.
descriptionstringsite taglineMeta description.
imagestringbrand OG imageOG / Twitter image.
urlstringcurrent routeCanonical URL.
type'website' | 'article' | 'product''website'OG type.
publishedTimestringarticle:published_time (article only).
modifiedTimestringarticle:modified_time (article only).
authorstringarticle:author (article only).
sectionstringarticle:section (article only).
tagsstring[]article:tag (article only, one per tag).
keywordsstring[]<meta name="keywords">. Distinct from tags so you can have separate lists for meta vs article schema.
alternate{ hreflang: string; href: string }[]<link rel="alternate" hreflang="..." href="..."> for international SEO. Pass { hreflang: 'x-default', ... } for the default-locale variant.
noindexbooleanfalseSet robots to noindex, nofollow.

Sets title template "{page} | {site}" automatically. Does not emit empty <meta property="..." content=""> tags — when an image / twitter handle / publish date isn't set, the corresponding tag is skipped entirely.

cleanSchema()

Strips undefined / null / empty-array values from a schema object. Useful when building custom schema shapes:

const schema = cleanSchema({
  '@context': 'https://schema.org',
  '@type': 'Thing',
  name: 'X',
  description: undefined,    // removed
  image: [],                  // removed
  url: 'https://...',
})

AI Context

composable: useSchema
package: "@xenterprises/nuxt-x-affiliate"
use-when: >
  Building a review or product page that needs Google rich-result eligibility.
  useProductSchema() is the highest-ROI call — drives star SERPs.
  useArticleSchema({ type: 'BlogPosting', ... }) is the swap target for
  hand-rolled BlogPosting JSON-LD on articles/[slug].vue pages — it
  supports the full Schema.org article shape (articleSection, keywords,
  wordCount, articleBody, inLanguage, isAccessibleForFree).
  For review pages, call useArticleSchema + useProductSchema together
  (the dual-block pattern Google recommends). useFAQSchema() drives FAQ
  rich results. useBreadcrumbSchema() drives breadcrumb SERPs.
  useOrganizationSchema() + usePersonSchema() are called once site-wide
  from app.vue or a layout. useWebSiteSchema({ searchUrlTemplate })
  enables the Google sitelinks searchbox. useSiteMeta() handles all
  standard meta tags (title, description, OG, Twitter Card, canonical,
  hreflang via alternate[], robots) — single call replaces the
  useSeoMeta + useHead boilerplate that lived on every page.
  Call useOrganizationSchema() + usePersonSchema() once in app.vue.
  Call useSiteMeta() + useArticleSchema() + useBreadcrumbSchema() on
  articles/[slug].vue. Call useSiteMeta() + useArticleSchema() +
  useProductSchema() + useBreadcrumbSchema() + useFAQSchema() on
  reviews/[slug].vue. Every composable auto-injects a
  <script type="application/ld+json"> block — no template wiring required.
Copyright © 2026