Files

33 KiB
Raw Permalink Blame History

Phase 7: SEO Blog - Research

Researched: 2026-04-22 Domain: JSON-LD Article/Breadcrumb/Blog, i18n sitemap, SSR SEO meta Confidence: HIGH

<user_constraints>

User Constraints (from CONTEXT.md)

Locked Decisions

  • D-01 — Install nuxt-schema-org (Nuxt SEO family). Typed defineArticle() / defineBreadcrumb() API, auto-merge with site.url, locale-aware FR/EN. No hand-rolled useHead({ script }).
  • D-02 — On /blog/[slug]: useSchemaOrg([defineArticle(...), defineBreadcrumb(...)]). Article fields: headline, description, image, datePublished, dateModified, author (Person Killian), publisher (Person Killian), inLanguage (fr-FR / en-US), mainEntityOfPage.
  • D-03 — On /blog: useSchemaOrg([defineCollectionPage(...)]) or minimal Blog equivalent. Breadcrumb Home → Blog.
  • D-04 — Do NOT install @nuxtjs/seo umbrella bundle. Cherry-pick nuxt-schema-org only (nuxt-og-image deferred).
  • D-05 — og:image hybrid: frontmatter image: if present, else static fallback /og-blog-default.jpg (1200×630, to create under public/).
  • D-06 — Helper resolveOgImage(article) returning absolute URL (prefixed with site.url), used by both useSeoMeta({ ogImage }) AND defineArticle({ image }) for consistency.
  • D-07 — Dynamic og:image via nuxt-og-image (Satori) explicitly deferred.
  • D-08 — Nitro endpoint server/api/__sitemap__/urls.ts queries blog_fr + blog_en (draft=false), returns { loc, lastmod, alternatives: [{ hreflang, href }] }. Referenced via sitemap.sources in nuxt.config.ts.
  • D-09lastmod = dateModified (= updated frontmatter if present, else date).
  • D-10 — Drafts (draft: true) EXCLUDED from sitemap. Remain accessible via direct URL.
  • D-11 — hreflang alternates per slug pair: if slug exists in FR AND EN → cross-declared hreflang="fr" + hreflang="en" + x-default → FR. If article exists in only one language → no alternate.
  • D-12author and publisher = single Person Killian constant, defined in shared helper (app/utils/seo-person.ts) or global schema-org config (useSchemaOrg in app.vue with defineWebSite + definePerson, inherited by child Article).
  • D-13dateModified source: optional updated frontmatter field (add updated.optional() to blog_fr/blog_en Zod schema). If absent → dateModified = date. No git mtime (Docker build has no .git).
  • D-14 — Extend blog_fr/blog_en collections with updated: z.string().optional() and image: z.string().optional().
  • D-15[slug].vue useSeoMeta enriched with: ogImage, ogUrl (localized canonical), ogLocale (fr_FR/en_US), ogLocaleAlternate (other locale if bilingual article), twitterCard: 'summary_large_image', twitterImage, articlePublishedTime, articleModifiedTime, articleAuthor.
  • D-16/blog index useSeoMeta enriched with ogImage (= /og-blog-default.jpg absolute), ogType: 'website', ogLocale, ogLocaleAlternate.

Claude's Discretion

  • Exact naming of og:image resolution helper (D-06)
  • Exact description format of Blog/CollectionPage JSON-LD listing (D-03)
  • Global definePerson in app.vue vs inline author in each defineArticle (→ recommendation below: global)
  • Exact design of /og-blog-default.jpg (branded fallback)

Deferred Ideas (OUT OF SCOPE)

  • Dynamic og:image via nuxt-og-image (Satori)
  • Global JSON-LD WebSite + Person on home (separate SEO phase)
  • Structured internal links /hytale ↔ articles (= SEO-14, Phase 8)
  • git mtime for dateModified
  • Exhaustive BlogPosting[] JSON-LD on /blog (noise for Google)

</user_constraints>

<phase_requirements>

Phase Requirements

ID Description Research Support
SEO-10 useSeoMeta() par article — title, description, og:title, og:description, og:image uniques §useSeoMeta Enrichment (article page)
SEO-11 JSON-LD Article per post — author, datePublished, dateModified, headline §nuxt-schema-org defineArticle pattern
SEO-12 Sitemap étendu — URLs /blog/[slug] + /en/blog/[slug] §Nitro sitemap endpoint + sources config
SEO-13 Open Graph image per article — frontmatter or branded fallback §og:image Resolution (resolveOgImage helper)
SEO-15 BreadcrumbList JSON-LD on blog pages (Home → Blog → Article) §defineBreadcrumb pattern

</phase_requirements>

Summary

Phase 7 extends the already-shipped blog (Phase 5/6) with three orthogonal SEO layers: (1) JSON-LD structured data via nuxt-schema-org (Nuxt SEO family, package nuxt-schema-org v6.x, native Nuxt 4 compat), (2) enriched Open Graph meta via the existing useSeoMeta composable (adding ogImage, ogUrl, ogLocaleAlternate, articlePublishedTime, articleModifiedTime), and (3) a dynamic Nitro sitemap source endpoint that feeds @nuxtjs/sitemap with /blog/[slug] URLs + hreflang alternates.

The existing stack already has three assets that make this cheap: site.url is set in nuxt.config.ts > site, @nuxtjs/sitemap v8 is installed and wired, and useLocaleHead({ seo: true }) in app/app.vue already emits global hreflang <link> tags. Phase 7 never replaces any of this — it augments.

Primary recommendation: Install nuxt-schema-org via npx nuxt module add schema-org. Declare a global useSchemaOrg([definePerson(killian), defineWebSite(...)]) in app/app.vue. Use page-level useSchemaOrg([defineArticle({...}), defineBreadcrumb({...})]) in [slug].vue — it auto-links author/publisher by graph @id to the global Person. For the sitemap, create server/api/__sitemap__/urls.ts using defineSitemapEventHandler + server-side queryCollection(event, 'blog_fr') (pass event as first arg — critical).

Architectural Responsibility Map

Capability Primary Tier Secondary Tier Rationale
JSON-LD Article/Breadcrumb Frontend Server (SSR) Must be in initial HTML for crawlers; page-level useSchemaOrg emits <script type="application/ld+json"> server-rendered
JSON-LD Person/WebSite global Frontend Server (SSR) Declared once in app.vue, inherited by all pages via nuxt-schema-org graph
useSeoMeta enrichment Frontend Server (SSR) Tags must exist in initial HTML (curl validation) — no client hydration
Sitemap URL generation Nitro server route /api/__sitemap__/urls runs at request time (or build for SSG) and feeds @nuxtjs/sitemap
og:image URL building Frontend Server (SSR) Shared util Same helper used by useSeoMeta AND defineArticleapp/utils/ location for both page + schema use
hreflang alternates (per-URL) Nitro server route Listing-level alternates already emitted by useLocaleHead at page level; per-article alternates must live in the sitemap feed
Content schema extension Build time content.config.ts Zod schema change → re-ingest on next nuxt dev/build

Standard Stack

Core

Library Version Purpose Why Standard
nuxt-schema-org ^6.0.4 JSON-LD via defineArticle, defineBreadcrumb, definePerson, defineWebSite, useSchemaOrg Official Nuxt SEO family, SSR-safe, auto-merges site.url, graph @id inheritance, used by Nuxt team [VERIFIED: nuxtseo.com/docs/schema-org/getting-started/installation]
@nuxtjs/sitemap ^8.0.12 (installed) Sitemap generation + sources config for dynamic URLs Already installed and functional for existing routes [VERIFIED: package.json]
@nuxt/content ^3.13.0 (installed) queryCollection in Nitro routes (must pass event as first arg in server ctx) Already installed [VERIFIED: package.json + content.nuxt.com/docs/utils/query-collection]
@nuxtjs/i18n ^10.2.4 (installed) useLocaleHead({ seo: true }) for global hreflang, localePath() for canonical URLs Already installed [VERIFIED: package.json + app/app.vue]

Supporting

Library Version Purpose When to Use
@unhead/vue (transitive via Nuxt) (bundled) useSeoMeta typed 100+ meta keys incl. articlePublishedTime, articleModifiedTime, ogLocaleAlternate Already in use — just add fields [VERIFIED: unhead.unjs.io + nuxt.com/docs/4.x/api/composables/use-seo-meta]

Alternatives Considered

Instead of Could Use Tradeoff
nuxt-schema-org Hand-rolled useHead({ script: [{ type:'application/ld+json', innerHTML: ... }] }) Schema.org drift, no typing, repetition across pages — rejected by D-01
nuxt-schema-org @nuxtjs/seo umbrella Pulls redundant modules (link-checker, robots-already-handled) — rejected by D-04
Nitro sitemap endpoint Static XML file Drafts filter can't be dynamic, hreflang alternates require code — rejected by D-08
Global definePerson in app.vue Inline author: in each defineArticle Inline is repetitive and creates duplicate Person nodes in graph; global + @id ref is canonical [VERIFIED: nuxtseo.com/docs/schema-org/guides/setup-identity]

Installation:

npx nuxt module add schema-org
# (equivalent to: pnpm add -D nuxt-schema-org && add 'nuxt-schema-org' to modules[])

Version verification: nuxt-schema-org current version is 6.0.4 per nuxtseo.com installation page [CITED: nuxtseo.com/docs/schema-org/getting-started/installation, fetched 2026-04-22]. Verify with pnpm view nuxt-schema-org version before install.

Architecture Patterns

System Architecture Diagram

[ Browser / Crawler ]
        │
        ▼ GET /fr/blog/my-slug
[ Nuxt SSR Renderer ]
        │
        ├── app.vue
        │     ├── useLocaleHead({ seo: true }) ──► <link rel="alternate" hreflang="fr|en|x-default">
        │     └── useSchemaOrg([definePerson(killian), defineWebSite]) ──► Global JSON-LD graph
        │
        └── pages/blog/[slug].vue
              ├── queryCollection('blog_fr').path(...).first() ──► page data
              ├── useSeoMeta({ title, ogImage, ogUrl, articlePublishedTime, ... }) ──► <meta> tags
              └── useSchemaOrg([defineArticle(...), defineBreadcrumb(...)]) ──► <script type="application/ld+json">
                        │
                        └── author: { '@id': '#killian' } ──► resolves to global Person node

[ Browser / Crawler ]
        │
        ▼ GET /sitemap.xml
[ @nuxtjs/sitemap ]
        │
        ├── source: /api/__sitemap__/urls   (Nitro route)
        │     ├── queryCollection(event, 'blog_fr').where('draft','=',false).all()
        │     ├── queryCollection(event, 'blog_en').where('draft','=',false).all()
        │     └── Map to SitemapUrl[] with { loc, lastmod, alternatives: [{hreflang, href}] }
        │
        └── Merges with auto-discovered pages + i18n routes ──► <urlset> XML
app/
  utils/
    seo-person.ts          # Killian Person constant (id, name, url, sameAs, image)
    resolve-og-image.ts    # resolveOgImage(article) → absolute URL
  app.vue                  # ADD: useSchemaOrg([definePerson, defineWebSite])
  pages/blog/
    [slug].vue             # ADD: useSchemaOrg([defineArticle, defineBreadcrumb]); EXTEND useSeoMeta
    index.vue              # ADD: useSchemaOrg([defineCollectionPage, defineBreadcrumb]); EXTEND useSeoMeta
server/
  api/
    __sitemap__/
      urls.ts              # NEW: defineSitemapEventHandler
content.config.ts          # EXTEND: blogSchema + updated.optional(), image already present
public/
  og-blog-default.jpg      # NEW: 1200×630 branded fallback
nuxt.config.ts             # ADD: 'nuxt-schema-org' to modules; sitemap.sources

Pattern 1: Global Schema Identity (app.vue)

What: Declare Person + WebSite once so every page's defineArticle inherits author/publisher by graph @id. When to use: Always for a single-author portfolio blog (D-12). Example:

// app/utils/seo-person.ts
export const KILLIAN_PERSON_ID = '#killian'
export const killianPerson = {
  '@id': KILLIAN_PERSON_ID,
  name: "Killian' Dal-Cin",
  url: 'https://killiandalcin.fr',
  jobTitle: 'Hytale Plugin Developer',
  sameAs: [
    'https://linkedin.com/in/killian-dal-cin',
    'https://gitea.kamisama.ovh/kayjaydee',
  ],
} as const
<!-- app/app.vue -->
<script setup lang="ts">
import { killianPerson } from '~/utils/seo-person'
const { locale } = useI18n()
const head = useLocaleHead({ seo: true })

useHead({
  htmlAttrs: { lang: locale },
  link: computed(() => head.value.link || []),
  meta: computed(() => head.value.meta || []),
})

// Global graph: Person + WebSite (inherited by child defineArticle via @id)
useSchemaOrg([
  definePerson(killianPerson),
  defineWebSite({
    name: "Killian' Dal-Cin — Hytale Plugin Developer",
    inLanguage: ['fr-FR', 'en-US'],
  }),
])
</script>

Source: [CITED: nuxtseo.com/docs/schema-org/guides/setup-identity, nuxtseo.com/docs/schema-org/guides/default-schema-org]

Pattern 2: Article Page JSON-LD + Meta

Example:

<!-- app/pages/blog/[slug].vue (additions) -->
<script setup lang="ts">
import { KILLIAN_PERSON_ID } from '~/utils/seo-person'
import { resolveOgImage } from '~/utils/resolve-og-image'

// ... existing page query from current [slug].vue ...

const siteUrl = 'https://killiandalcin.fr'
const ogImage = computed(() => resolveOgImage(page.value))                  // absolute URL
const canonicalUrl = computed(() => `${siteUrl}${localePath('/blog/' + slug)}`)
const publishedIso = computed(() => page.value?.date)
const modifiedIso = computed(() => page.value?.updated ?? page.value?.date)  // D-13

// Detect bilingual pair (checked at build via paired slug) to emit ogLocaleAlternate
const { data: altExists } = await useAsyncData(
  `blog-alt-${locale.value}-${slug}`,
  () => (isFr.value
    ? queryCollection('blog_en').path(`/en/blog/${slug}`).first()
    : queryCollection('blog_fr').path(`/fr/blog/${slug}`).first()),
  { watch: [locale] },
)

useSeoMeta({
  title: () => page.value?.title,
  description: () => page.value?.description,
  ogTitle: () => page.value?.title,
  ogDescription: () => page.value?.description,
  ogType: 'article',
  ogImage,
  ogUrl: canonicalUrl,
  ogLocale: () => (isFr.value ? 'fr_FR' : 'en_US'),
  ogLocaleAlternate: () => (altExists.value ? (isFr.value ? ['en_US'] : ['fr_FR']) : []),
  twitterCard: 'summary_large_image',
  twitterImage: ogImage,
  articlePublishedTime: publishedIso,
  articleModifiedTime: modifiedIso,
  articleAuthor: () => "Killian' Dal-Cin",
})

useSchemaOrg([
  defineArticle({
    headline: () => page.value?.title,
    description: () => page.value?.description,
    image: ogImage,                                   // absolute URL (same helper)
    datePublished: publishedIso,
    dateModified: modifiedIso,
    inLanguage: () => (isFr.value ? 'fr-FR' : 'en-US'),
    author: { '@id': KILLIAN_PERSON_ID },             // refs global definePerson
    publisher: { '@id': KILLIAN_PERSON_ID },
    mainEntityOfPage: canonicalUrl,
  }),
  defineBreadcrumb({
    itemListElement: [
      { name: t('blog.breadcrumb.home'), item: localePath('/') },
      { name: t('blog.breadcrumb.blog'), item: localePath('/blog') },
      { name: () => page.value?.title ?? '' },
    ],
  }),
])
</script>

Source: [CITED: nuxtseo.com/docs/schema-org/api/define-article, unhead.unjs.io/docs/schema-org/api/composables/use-schema-org]

Pattern 3: Nitro Sitemap Source Endpoint

What: Dynamic URL feed consumed by @nuxtjs/sitemap via sources config. Critical: In Nitro routes, queryCollection requires event as first argument (verified). Always use literal collection strings. Example:

// server/api/__sitemap__/urls.ts
import { defineSitemapEventHandler } from '#imports'
import type { SitemapUrl } from '#sitemap/types'

const SITE_URL = 'https://killiandalcin.fr'

export default defineSitemapEventHandler(async (event) => {
  const [frArticles, enArticles] = await Promise.all([
    queryCollection(event, 'blog_fr')
      .where('draft', '=', false)
      .order('date', 'DESC')
      .select('path', 'date', 'updated')
      .all(),
    queryCollection(event, 'blog_en')
      .where('draft', '=', false)
      .order('date', 'DESC')
      .select('path', 'date', 'updated')
      .all(),
  ])

  // Build slug → { fr?, en? } index for alternate pairing (D-11)
  type Row = { path: string; date: string; updated?: string }
  const extractSlug = (p: string) => p.split('/').filter(Boolean).pop()!
  const index = new Map<string, { fr?: Row; en?: Row }>()
  for (const a of frArticles) {
    const s = extractSlug(a.path); const e = index.get(s) ?? {}; e.fr = a; index.set(s, e)
  }
  for (const a of enArticles) {
    const s = extractSlug(a.path); const e = index.get(s) ?? {}; e.en = a; index.set(s, e)
  }

  const urls: SitemapUrl[] = []
  for (const [slug, pair] of index) {
    const alternatives = []
    if (pair.fr) alternatives.push({ hreflang: 'fr', href: `${SITE_URL}/fr/blog/${slug}` })
    if (pair.en) alternatives.push({ hreflang: 'en', href: `${SITE_URL}/en/blog/${slug}` })
    if (pair.fr && pair.en) {
      alternatives.push({ hreflang: 'x-default', href: `${SITE_URL}/fr/blog/${slug}` })
    }
    // else: single-language article → no alternatives (D-11)
    const altsForEntry = (pair.fr && pair.en) ? alternatives : []

    if (pair.fr) {
      urls.push({
        loc: `/fr/blog/${slug}`,
        lastmod: pair.fr.updated ?? pair.fr.date,     // D-09
        alternatives: altsForEntry,
      })
    }
    if (pair.en) {
      urls.push({
        loc: `/en/blog/${slug}`,
        lastmod: pair.en.updated ?? pair.en.date,
        alternatives: altsForEntry,
      })
    }
  }
  return urls
})
// nuxt.config.ts addition
export default defineNuxtConfig({
  // ... existing ...
  modules: [/* ... */, 'nuxt-schema-org'],
  sitemap: {
    sources: ['/api/__sitemap__/urls'],
  },
})

Source: [CITED: nuxtseo.com/docs/sitemap (dynamic URLs guide), content.nuxt.com/docs/utils/query-collection (server usage)]

Pattern 4: resolveOgImage helper (D-06)

// app/utils/resolve-og-image.ts
const SITE_URL = 'https://killiandalcin.fr'
const FALLBACK = '/og-blog-default.jpg'

export function resolveOgImage(article?: { image?: string } | null): string {
  const raw = article?.image?.trim() || FALLBACK
  if (raw.startsWith('http://') || raw.startsWith('https://')) return raw
  return `${SITE_URL}${raw.startsWith('/') ? raw : `/${raw}`}`
}

Anti-Patterns to Avoid

  • Inline author in every defineArticle: Creates duplicate Person nodes in the graph. Use global definePerson + author: { '@id': KILLIAN_PERSON_ID } ref instead.
  • Relative ogImage: Breaks social share crawlers. og:image MUST be absolute (why resolveOgImage prefixes site.url).
  • queryCollection('blog_' + locale.value) in server route: Vite extractor can't analyze the variable (Phase 5 gotcha) AND server routes need event as first arg. Always literal: queryCollection(event, 'blog_fr') + queryCollection(event, 'blog_en') branch.
  • Hand-rolled useHead({ script: [{ innerHTML: JSON.stringify(...) }] }): D-01 explicitly rejects this.
  • Adding /sitemap.xml static file: FIX-01 already removed it — do NOT re-add.

Don't Hand-Roll

Problem Don't Build Use Instead Why
JSON-LD Article/Breadcrumb Custom useHead({ script }) with hand-written @context/@type nuxt-schema-org defineArticle / defineBreadcrumb Schema.org drift, no typing, no graph @id resolution, no locale merging
Person identity duplication Inline author in each page Global definePerson in app.vue + @id refs Canonical graph, single source of truth
Sitemap XML serialization Hand-crafted XML string defineSitemapEventHandler returning SitemapUrl[] Auto xhtml:link generation, URL encoding, merge with auto-discovered routes
hreflang <link> at page level Custom useHead({ link }) Existing useLocaleHead({ seo: true }) in app.vue (already in place) Already ships correct tags; don't duplicate
og:image URL building Copy-pasted string concat Shared resolveOgImage(article) util D-06 mandates one helper used by BOTH useSeoMeta AND defineArticle

Runtime State Inventory

Phase type: Additive (new schema fields, new files, new module install). No rename/migration.

Category Items Found Action Required
Stored data @nuxt/content SQLite DB caches parsed markdown — new schema fields (updated) require cache invalidation on first run Document: delete node_modules/.cache/content + .nuxt after schema change (Phase 6 precedent)
Live service config None None — verified by inspection
OS-registered state None None
Secrets/env vars None new None
Build artifacts .output/ (Docker build) — sitemap is regenerated each build; no stale artifact risk None

useSeoMeta Enrichment — Exact Keys

Verified against Nuxt 4 docs and Unhead typings [CITED: nuxt.com/docs/4.x/api/composables/use-seo-meta, unhead.unjs.io/docs/head/api/composables/use-seo-meta]:

Key Type Maps to meta tag Notes
ogImage string | () => string <meta property="og:image"> Must be absolute URL
ogUrl string | () => string <meta property="og:url"> Canonical URL
ogLocale string | () => string <meta property="og:locale"> fr_FR or en_US (underscore, not dash)
ogLocaleAlternate string[] | () => string[] <meta property="og:locale:alternate"> (one per entry) Pass only the OTHER locale(s), not current
twitterCard 'summary' | 'summary_large_image' | ... <meta name="twitter:card"> 'summary_large_image' per D-15
twitterImage string <meta name="twitter:image"> Mirror of ogImage
articlePublishedTime string (ISO 8601) <meta property="article:published_time"> From frontmatter date
articleModifiedTime string (ISO 8601) <meta property="article:modified_time"> From updated ?? date
articleAuthor string | string[] <meta property="article:author"> Killian's name or URL

Reactive pattern: Wrap dynamic values in arrow functions (() => page.value?.title) — critical for useAsyncData-loaded content [VERIFIED: Nuxt 4 docs].

Common Pitfalls

Pitfall 1: queryCollection in Nitro route without event

What goes wrong: Returns empty or throws at runtime when /sitemap.xml is requested. Why it happens: Nuxt Content v3 server-side queryCollection requires the event object to resolve SQL binding per request. Client/SSR page context wires this automatically; Nitro routes don't. How to avoid: queryCollection(event, 'blog_fr') — always pass event first arg in server routes. Warning signs: Empty sitemap, or TypeScript error "Expected 2 arguments". Source: [VERIFIED: content.nuxt.com/docs/utils/query-collection + GitHub issue nuxt/content#3037].

Pitfall 2: Variable collection name in queryCollection

What goes wrong: Vite extractor can't statically analyze, query returns empty. Why it happens: @nuxt/content v3 uses a build-time Vite plugin to extract collection references for SQL codegen. Only string literals work. How to avoid: Use if (isFr) queryCollection(event, 'blog_fr') else queryCollection(event, 'blog_en') — both branches literal. Warning signs: Works in dev, breaks in build. Documented as Phase 5 gotcha in .planning/STATE.md.

Pitfall 3: Relative og:image URL

What goes wrong: Facebook/Twitter/LinkedIn crawlers fail to preview share cards. Why: Open Graph spec requires absolute URLs; social crawlers don't resolve relative paths. How to avoid: Use resolveOgImage() helper that always prefixes site.url. Test with curl localhost:3000/fr/blog/foo | grep 'og:image' — value must start with https://.

Pitfall 4: Duplicate Person nodes in JSON-LD graph

What goes wrong: Google Rich Results test flags multiple competing Person identities. Why: Inline author: { name: 'Killian' } in each defineArticle creates a fresh node. Global definePerson + @id ref resolves to one canonical node. How to avoid: Declare definePerson({ '@id': '#killian', ... }) in app.vue once. In articles: author: { '@id': '#killian' }. Verify via Rich Results test that graph contains exactly one Person.

Pitfall 5: Drafts leaking into sitemap

What goes wrong: Unpublished content appears in Google index. Why: Forgetting .where('draft', '=', false) in the sitemap endpoint. How to avoid: Apply the filter in server/api/__sitemap__/urls.ts — mirrors listing page (Phase 6 D-14).

Pitfall 6: Canonical URL drift with i18n prefix strategy

What goes wrong: ogUrl and mainEntityOfPage don't match the actual route. Why: @nuxtjs/i18n strategy prefix means even default locale has /fr/... prefix (verified in nuxt.config.ts). localePath('/blog/' + slug) already includes the prefix. How to avoid: Always build canonical as ${site.url}${localePath(...)} — never concat slug directly.

Pitfall 7: ogLocaleAlternate includes current locale

What goes wrong: Redundant/incorrect meta emission. Why: The key is for the other locales, not the current one. Current locale goes in ogLocale. How to avoid: Array contains only the counterpart when bilingual pair exists; empty array when single-language.

Pitfall 8: Schema change not reflected after hot-reload

What goes wrong: New updated field not queryable even with frontmatter populated. Why: @nuxt/content SQLite cache persists stale schema. Phase 6 Gotcha 06-01 precedent. How to avoid: rm -rf node_modules/.cache/content .nuxt then restart dev server after schema edit in content.config.ts.

Code Examples

All verified patterns embedded in §Architecture Patterns above (Patterns 14). Key quick reference:

Sitemap entry shape (per URL)

{
  loc: '/fr/blog/my-slug',
  lastmod: '2026-04-22',              // ISO string from updated ?? date
  alternatives: [
    { hreflang: 'fr', href: 'https://killiandalcin.fr/fr/blog/my-slug' },
    { hreflang: 'en', href: 'https://killiandalcin.fr/en/blog/my-slug' },
    { hreflang: 'x-default', href: 'https://killiandalcin.fr/fr/blog/my-slug' },
  ],
}

content.config.ts schema extension (D-14)

const blogSchema = z.object({
  title: z.string(),
  description: z.string(),
  date: z.string(),
  updated: z.string().optional(),            // NEW (D-13/D-14)
  tags: z.array(z.string()).optional(),
  image: z.string().optional(),              // already present — confirm
  draft: z.boolean().optional().default(false),
  wordCount: z.number().optional(),
  minutes: z.number().optional(),
})

State of the Art

Old Approach Current Approach When Changed Impact
Hand-rolled <script type="application/ld+json"> via useHead nuxt-schema-org defineArticle/defineBreadcrumb with graph @id inheritance Nuxt SEO family v5→v6 (20242025) Less code, auto site.url merge, locale-aware
Static sitemap.xml in public/ @nuxtjs/sitemap v8 with sources: ['/api/...'] @nuxtjs/sitemap v7+ Dynamic URLs, hreflang alternates, drafts filter
queryContent() (v2) queryCollection(event, 'name') in Nitro (v3) @nuxt/content v3 (2024) Typed collections via Zod, explicit event arg in server

Deprecated/outdated:

  • Nuxt Content v2 queryContent — replaced by v3 queryCollection
  • @nuxtjs/seo umbrella install — rejected by D-04 (bloats with link-checker, redundant robots)

Assumptions Log

# Claim Section Risk if Wrong
A1 defineSitemapEventHandler is the current canonical export name in @nuxtjs/sitemap v8 Pattern 3 Low — fallback to eventHandler + manual return. Verify on first commit.
A2 defineCollectionPage is the best fit JSON-LD type for /blog listing (vs defineBlog) D-03 sketch Low — both are valid; planner will finalize based on module export signatures.
A3 select() accepts field names as rest args in @nuxt/content v3 server context Pattern 3 Low — if not, use .all() and map at JS level; no functional impact, just slightly more payload.
A4 content.config.ts image field is already declared (shown in current file read) D-14 None — verified by reading content.config.ts.

All other claims are VERIFIED via code inspection, Nuxt SEO docs, or Nuxt Content docs (see Sources).

Open Questions

  1. Exact listing JSON-LD type — CollectionPage vs Blog vs ItemList?

    • What we know: D-03 leaves this to Claude's discretion; spec prefers minimal over exhaustive BlogPosting[].
    • What's unclear: nuxt-schema-org v6 exports — defineCollectionPage is standard; defineWebPage with @type: 'CollectionPage' also works.
    • Recommendation: Use defineWebPage({ '@type': 'CollectionPage' }) + defineBreadcrumb. Avoid emitting individual BlogPosting nodes (noise). Planner confirms via pnpm view nuxt-schema-org exports.
  2. /og-blog-default.jpg asset creation — who, when, what tool?

    • What we know: 1200×630 branded fallback (D-05).
    • What's unclear: Design ownership.
    • Recommendation: Planner creates a task "design + drop /public/og-blog-default.jpg" — use Figma template or simple gradient + logo. Non-blocking: can ship with a placeholder JPG and swap later.
  3. Should useLocaleHead({ seo: true }) in app.vue be reviewed for completeness?

    • What we know: It already emits hreflang <link> tags at page level (not in sitemap).
    • What's unclear: Whether it also emits og:locale:alternate (redundant with our new useSeoMeta usage).
    • Recommendation: Planner inspects generated HTML in dev — if useLocaleHead already emits og:locale:alternate, do NOT duplicate in useSeoMeta; only set ogLocale per page.

Environment Availability

Dependency Required By Available Version Fallback
Node.js Build + Nitro 22 (Dockerfile)
pnpm Install lockfile-tracked
nuxt-schema-org New install pnpm add -D nuxt-schema-org (zero cost)
@nuxtjs/sitemap Already installed ^8.0.12
@nuxt/content Already installed ^3.13.0
Existing site.url config Referenced https://killiandalcin.fr (nuxt.config.ts)
/og-blog-default.jpg asset og:image fallback Ship with placeholder JPG; swap design later

Missing dependencies with no fallback: None. Missing dependencies with fallback: og-blog-default.jpg image asset (design task, non-blocking).

Project Constraints (from CLAUDE.md)

  • SSR mandatory: Every SEO tag MUST be present in initial HTML (curl validation). No client-only JSON-LD injection. nuxt-schema-org is SSR-safe by design.
  • Zero cost deps: nuxt-schema-org is MIT open-source. No paid service.
  • Nuxt UI v3 priority over custom: No UI work in this phase — pure SEO metadata. N/A.
  • TypeScript strict: All new files (seo-person.ts, resolve-og-image.ts, urls.ts) must type-check with pnpm typecheck (same bar as Phase 6).
  • Cookie-only persistence (no localStorage): No new persistence surface in this phase. N/A.
  • pnpm: Install via pnpm add -D nuxt-schema-org (or npx nuxt module add schema-org which detects pnpm).
  • GSD workflow enforcement: Phase 7 must be planned via /gsd-plan-phase and executed via /gsd-execute-phase. This research feeds the planner.

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

  • Nuxt SEO — Learn mastering-meta / schema-org (concept + identity patterns)
  • GitHub issues confirming queryCollection server-side event arg requirement (nuxt/content #3037)

Tertiary (LOW confidence)

  • None — all critical claims cross-verified.

Metadata

Confidence breakdown:

  • Standard stack: HIGH — package versions verified via package.json, nuxt-schema-org current version from official docs.
  • Architecture: HIGH — patterns directly from official Nuxt SEO + Nuxt Content docs; app.vue + existing pages read first-hand.
  • Pitfalls: HIGH — pitfalls 1, 2, 8 are repeated from Phase 5/6 gotchas (known ground truth); 37 from Open Graph spec + schema.org semantics.

Research date: 2026-04-22 Valid until: 2026-05-22 (30 days — stable SEO ecosystem; re-verify if Nuxt 5 or @nuxtjs/sitemap v9 ships)