diff --git a/.planning/phases/07-seo-blog/07-RESEARCH.md b/.planning/phases/07-seo-blog/07-RESEARCH.md
new file mode 100644
index 0000000..0eaec25
--- /dev/null
+++ b/.planning/phases/07-seo-blog/07-RESEARCH.md
@@ -0,0 +1,589 @@
+# Phase 7: SEO Blog - Research
+
+**Researched:** 2026-04-22
+**Domain:** JSON-LD Article/Breadcrumb/Blog, i18n sitemap, SSR SEO meta
+**Confidence:** HIGH
+
+
+## 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-09** — `lastmod` = `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-12** — `author` 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-13** — `dateModified` 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)
+
+
+
+
+## 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 |
+
+
+
+## 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 `` 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 `
+```
+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:**
+```vue
+
+
+```
+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:**
+```ts
+// 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()
+ 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
+})
+```
+
+```ts
+// 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)
+
+```ts
+// 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 `` 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 | `` | Must be absolute URL |
+| `ogUrl` | string \| () => string | `` | Canonical URL |
+| `ogLocale` | string \| () => string | `` | `fr_FR` or `en_US` (underscore, not dash) |
+| `ogLocaleAlternate` | string[] \| () => string[] | `` (one per entry) | Pass only the OTHER locale(s), not current |
+| `twitterCard` | 'summary' \| 'summary_large_image' \| ... | `` | `'summary_large_image'` per D-15 |
+| `twitterImage` | string | `` | Mirror of ogImage |
+| `articlePublishedTime` | string (ISO 8601) | `` | From frontmatter `date` |
+| `articleModifiedTime` | string (ISO 8601) | `` | From `updated` ?? `date` |
+| `articleAuthor` | string \| string[] | `` | 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 1–4). Key quick reference:
+
+### Sitemap entry shape (per URL)
+```ts
+{
+ 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)
+```ts
+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 `