# Migration Next.js 16 → Astro 6 (ElevaSEO)

> Démarrée le 2026-04-22. Branche `feat/astro-migration`. Sous-dossier `astro/` du repo `eleva-rebrand`. Base technique : `bastienallain/astro6-seo-template`. **Plan directeur** : `~/.claude/plans/zippy-meandering-peach.md`.

---

## SEO préservé — les 10 points intransigeants

Toute la logique SEO actuelle du site Next.js est **portée telle quelle** dans Astro. On garde ce qui marche.

1. **URL articles** : `/blog/{categorySlug}/{slug}` en FR, `/en/blog/{categorySlug}/{slug}` en EN — identiques au bit près. Aucune rupture d'indexation.
2. **Catégories** : lowercase (`seo`, `wordpress`, `performance`, `headless`, `ecommerce`, `cro`, `accessibilite`, …). Enum Zod calibré sur les valeurs réellement présentes dans les 290 MDX.
3. **Sitemap** : port exact de `app/sitemap.ts` via un endpoint custom `src/pages/sitemap.xml.ts`. On n'utilise **pas** `@astrojs/sitemap` générique — on réplique la sortie actuelle (staticPages + categorySlugs hubs + translationGroups avec alternates `fr`/`en`/`x-default`, lastmod par post).
4. **RSS** : port exact de `lib/rss.ts` — 50 items max, WebSub hub `pubsubhubbub.appspot.com`, `<atom:link rel="self">` + `<atom:link rel="hub">`, `pubDate` basé sur `updatedDate || pubDate`, CDATA title/description, `<author>bastien@elevaseo.com (Bastien Allain)</author>`, Cache-Control `s-maxage=3600`.
5. **robots.txt** : port exact de `app/robots.ts` — allow `/`, disallow `/api/` + `/cdn-cgi/`, 3 sitemaps (site + RSS FR + RSS EN).
6. **JSON-LD** : port de `lib/json-ld.ts` `buildBlogPostingJsonLd()` intégral (BlogPosting avec Person author + sameAs LinkedIn, ImageObject logo, wordCount, articleSection, inLanguage, isFamilyFriendly). Plus Organization + WebSite+SearchAction + SiteNavigationElement + ProfilePage + BreadcrumbList + HowTo.
7. **Redirects** : port intégral de `next.config.js` `redirects()` + `lib/blog-redirects.js` (21 KB) → `astro/vercel.json` `redirects[]`.
8. **Headers HTTP** : port de `next.config.js` `headers()` — Cache-Control (images 30j, fonts 1an immutable, `/_astro/` immutable), `X-Robots-Tag: noindex, nofollow` sur `/api/*`, security (X-Content-Type-Options, X-Frame-Options SAMEORIGIN, Referrer-Policy, Permissions-Policy, COOP) → `astro/vercel.json` `headers[]`.
9. **IndexNow** : clé `540b2517d9dfd97043c9ee3ce5273c69` conservée dans `astro/public/`.
10. **Hreflang** : alternates FR/EN + x-default sur chaque URL du sitemap + `<link rel="alternate">` dans `<head>` via `createMetadata()`. Pattern validé dans `Astro-i18n-localized-slugs.md`.

---

## Mapping Next.js → Astro (features)

| Next.js | Astro | Notes |
|---|---|---|
| `app/[locale]/page.tsx` | `src/pages/index.astro` + `src/pages/en/index.astro` | FR root sans prefix |
| `app/[locale]/about/page.tsx` | `about.astro` + `en/about.astro` | ProfilePage JSON-LD |
| `app/[locale]/contact/page.tsx` | `contact.astro` + `en/contact.astro` | Form React island |
| `app/[locale]/terms/` + `privacy-policy/` | `terms.astro` + `privacy.astro` mirror | — |
| `app/[locale]/services/page.tsx` | `services/index.astro` + mirror | Hub |
| `app/[locale]/services/**/page.tsx` (10 pages) | `services/[...path].astro` avec `getStaticPaths` | Données dans `src/config/services.ts` |
| `app/[locale]/audits/**/page.tsx` (4 pages) | `audits/[...path].astro` | Idem pattern |
| `app/[locale]/blog/page.tsx` | `blog/index.astro` + mirror EN | Hub blog |
| `app/[locale]/blog/[category]/page.tsx` | `blog/[category]/index.astro` + mirror | Liste par catégorie |
| `app/[locale]/blog/[category]/[slug]/page.tsx` | `blog/[category]/[...slug].astro` + mirror EN | Article MDX |
| `app/[locale]/not-found.tsx` | `404.astro` | — |
| `app/api/contact/route.ts` | `src/pages/api/contact.ts` | Resend + OpenPanel server |
| `app/sitemap.ts` | `src/pages/sitemap.xml.ts` | Port intégral |
| `app/robots.ts` | `src/pages/robots.txt.ts` | Port intégral |
| `app/blog/rss.xml/route.ts` | `src/pages/blog/rss.xml.ts` | Port intégral `lib/rss.ts` |
| `app/en/blog/rss.xml/route.ts` | `src/pages/en/blog/rss.xml.ts` | Idem EN |
| `app/[locale]/opengraph-image.tsx` + blog OG | satori + resvg (pattern template) | À recalibrer sur le nouveau design |
| `next.config.js` redirects + headers | `astro/vercel.json` | — |
| `lib/blog-redirects.js` | Intégré dans `vercel.json` redirects | — |
| `proxy.ts` (middleware next-intl + exclusions) | `src/middleware.ts` (déjà template : security headers) | Pas besoin de middleware i18n (routing file-based) |
| `content-collections` + `@content-collections/mdx` | Content Collections v2 + `@astrojs/mdx` | Natif Astro |
| `next-intl` | `src/i18n/` (dict + utils maison) | FR default, EN `/en/` |
| `@openpanel/nextjs` | `src/components/analytics/OpenPanel.astro` | Pattern script-tag (voir `OpenPanel.md`) |
| `@next/third-parties` GA4 | `<script async gtag/js>` inline dans BaseLayout | `G-T5SXH37NM6` |
| `@calcom/embed-react` | iframe ou `<cal-booking-flow>` custom element | — |

---

## Frontmatter MDX — normalisation

290 articles à migrer (146 FR + 144 EN) via `scripts/migrate-mdx-frontmatter.ts` (tsx one-shot).

| Next.js | Astro | Action |
|---|---|---|
| `date: "2026-03-05"` | `pubDate: Date` | Rename + coerce |
| `lastmod: "2026-03-05"` | `updatedDate: Date` **(obligatoire)** | Rename + coerce. Si absent du frontmatter → généré au build (fallback = `pubDate`) |
| `translationKey: "…"` | `translationId: "…"` | Rename |
| `category: "SEO"` | `category: "seo"` | Lowercase (aligne avec URL `categorySlug`) |
| `categorySlug: "seo"` | — | Supprimé (redondant, `category` === slug) |
| `author: "Bastien Allain"` | `author: reference("authors")` → `bastien` | Remplacer par ref |
| `authorImage`, `authorTwitter` | Centralisé dans `authors/bastien.json` | Supprimer |
| `image: "/blog/x.webp"` | `image: string` URL publique | Garder (pas d'optimisation immédiate) |
| `keywords[]` + `tags[]` | `tags: string[]` | Fusionner + dédupliquer |
| `published: true` | Supprimer (ou `draft: false`) | Inverser convention |
| `isFamilyFriendly` | — | Supprimer (injecté direct dans JSON-LD) |
| `locale: "fr"` | `locale: "fr"` | OK |

---

## Composants MDX custom

| Next.js | Astro | Action |
|---|---|---|
| `<HowTo>` | `components/mdx/HowTo.astro` | Port 1:1 + JSON-LD HowTo (intact) |
| `<FAQBox>` + `<FAQItem>` | `components/mdx/FAQ.astro` + `FAQItem.astro` | Accordéon `<details>`. **Pas** de schema FAQPage (déprécié 2026 hors gov/health). |
| `<BlogImage>` | `components/mdx/BlogImage.astro` | `<img loading="lazy">` (URL string) |
| `<CopyButton>` | `<script is:inline>` | Vanilla JS sur `<pre><code>` |
| `<ComparisonChart>`, `<CostChart>`, `<PerformanceChart>`, `<RadarComparisonChart>`, `<TrendLineChart>` | React islands (`client:visible`) | **Conservés** — recharts reste une dep. Ports en `.tsx` avec hydratation paresseuse pour perf. |

Enregistrement par page article : `<Content components={{ HowTo, FAQBox, FAQItem, BlogImage }} />`.

---

## Images — `public/` vs `astro:assets`

**Règle actuelle** : toutes les images legacy (290 articles) sont restées dans `public/blog/` et référencées par URL string (`image: "/blog/category-seo.webp"`). Zéro optimisation Astro, mais URLs 1:1 avec Google (crucial SEO).

**Pour les NOUVELLES images** (photos produit, illustrations custom, images d'en-tête d'articles futurs) → utiliser `astro:assets` :

1. Images **colocated** près du MDX ou dans `src/assets/`
2. Déclarer dans le schema Zod : `cover: image()` (helper fourni par `defineCollection({ schema: ({ image }) => ... })`)
3. Dans `.astro` : `<Image src={frontmatter.cover} alt={...} />` — format/size auto
4. Dans un React island (ex : `BlogMagazine`) : le `<Image>` component ne fonctionne pas dans un `.tsx`. Résolution : dans le `.astro` parent, appeler `getImage({ src: post.data.cover, format: "avif" })`, puis passer `.src` (string) au composant.

**Remote images** (CDN externe) : autoriser via `astro.config.ts` :
```ts
image: { remotePatterns: [{ protocol: "https", hostname: "cdn.example.com" }] }
```

**Migration future** : quand on ajoute une image à un article, la colocater dans `src/assets/blog/{slug}/` et migrer le frontmatter vers `image: ./cover.webp` (relatif). Garder les anciennes URL string en parallèle tant que Google les a indexées.

Source : <https://docs.astro.build/en/guides/images/>

---

## Pitfalls Astro → à garder en tête

1. **`class` vs `className`** — `.astro` utilise `class` (HTML standard), `.tsx` islands utilisent `className`.
2. **`{children}` devient `<slot />`** — Astro ne prop-forward pas les children, il utilise slots.
3. **Fragments `<>`** — pas nécessaires en `.astro` (multi-root autorisé).
4. **`key` et `ref`** — invalides sur les éléments Astro (OK dans islands React).
5. **Code fence vs body** — `.astro` sépare JS (dans `---`) et HTML (après `---`). Pas de JSX inline dans le code fence.
6. **Islands `client:*`** — `client:load`, `client:idle`, `client:visible`, `client:only="react"`. Sans directive, la composante React est **SSR pur** (pas d'hydratation).
7. **`import.meta.glob()`** remplace `getStaticProps` / `fs.readdir` pour charger des fichiers locaux.
8. **`trailingSlash: "always"`** — configuré sur le template, tous les liens internes doivent finir par `/`.
9. **Routing file-based strict** — `[...slug].astro` = catch-all, `[slug].astro` = single segment.
10. **`getStaticPaths` obligatoire** sur tout fichier `[param].astro` en output `static`. Retourne `{ params, props }[]`.
11. **Images optimisées** via `astro:assets` `<Image>` — prend un `ImageMetadata` (import) ou URL externe avec dimensions. URL publique en string → `<img>` classique.
12. **MDX components** — le mapping `components={{ h1: CustomHeading }}` fonctionne mais tags HTML bas de casse. Composants custom (`<HowTo>`) doivent être importés en haut du MDX OU passés en props via `<Content components={...} />`.
13. **`set:html`** = équivalent `dangerouslySetInnerHTML`. Pas d'injection par défaut.
14. **`Astro.glob` déprécié** — utiliser `import.meta.glob` + `astro:content` collections.
15. **Output static** — pas de SSR runtime. Les endpoints `.ts` dans `pages/` sont pré-rendus au build (sauf si `export const prerender = false` + adapter SSR).

---

## i18n — FR par défaut, pas de slugs catégorie localisés

**Différence clé vs `Astro-i18n-localized-slugs.md`** : nos slugs catégorie sont **identiques en FR et EN** (`/blog/seo/...` dans les deux locales). Donc pas besoin de `categorySlugs: { seo: { fr: "seo", en: "seo" } }` — un simple enum suffit.

Config Astro :
```ts
// astro.config.ts
i18n: {
  defaultLocale: "fr",
  locales: ["fr", "en"],
  routing: {
    prefixDefaultLocale: false,   // FR sans prefix
    fallbackType: "redirect",
  },
  fallback: { en: "fr" },
}
```

`getLocaleFromUrl` : `url.pathname.startsWith("/en")` → `"en"`, sinon `"fr"`.
`getLocalizedPath` : strip `/en` prefix pour FR, ajouter `/en` pour EN.

**Translation matching** : via `translationId` partagé entre FR et EN. `getPostTranslations(post)` retourne `{ fr, en }` pour le LanguageSwitcher.

---

## Analytics

### OpenPanel (self-hosted)
- Composant `src/components/analytics/OpenPanel.astro` — pattern script-tag (voir note `OpenPanel.md`)
- Env vars :
  - `PUBLIC_OPENPANEL_CLIENT_ID`
  - `PUBLIC_OPENPANEL_API_URL=https://analytics.elevaseo.app/api`
  - `PUBLIC_OPENPANEL_SCRIPT_URL=https://analytics.elevaseo.app/op1.js`
  - `OPENPANEL_CLIENT_SECRET` (server-only pour `src/pages/api/contact.ts`)
- Tracking serveur : fetch direct `POST /api/track` avec headers `openpanel-client-id` + `openpanel-client-secret`. Pas de SDK. `void fetch(...)` en fire-and-forget — Vercel Fluid Compute attend la requête avant shutdown.

### GA4
- `<script async src="https://www.googletagmanager.com/gtag/js?id=G-T5SXH37NM6">` + init inline dans BaseLayout.

---

## Règle de validation par phase

**Avant de déclarer une phase complète**, lancer impérativement :

```bash
openlogs pnpm dev
```

et lire l'intégralité de la sortie. `astro check` ne suffit pas — il attrape les erreurs TS/schema mais rate :
- les hydration mismatches (React islands)
- les erreurs de rendu MDX (composants manquants, import invalide)
- les routes/endpoints qui plantent au boot
- les 500 API au premier hit
- les assets introuvables dans `public/` ou `astro:assets`
- les imports cycliques / résolutions de chemins
- les problèmes de config adapter (Vercel, etc.)

Corriger TOUT ce que le log remonte avant de passer à la phase suivante. Inclure le résultat du log (vert ou rouge) dans le handoff de fin de phase.

## Commandes utiles

```bash
# Depuis astro/
cd astro
pnpm install                    # install deps
openlogs pnpm dev               # http://localhost:4321 AVEC logs piégés (mandatory en fin de phase)
pnpm dev                        # version courte si déjà validé
pnpm build                      # static build dans dist/
pnpm preview                    # preview build prod

# Outillage
pnpm astro add <integration>    # add @astrojs/<x>
pnpm astro check                # TS check
pnpm astro sync                 # regénère types .astro/ (content collections)

# Lint / format
pnpm lint
pnpm format

# Tests
pnpm test                       # vitest
pnpm test:e2e                   # playwright
```

---

## Structure cible

```
eleva-rebrand/  (branche feat/astro-migration)
├── astro/                        ← nouveau code
│   ├── astro.config.ts           ← FR default, trailingSlash always, Vercel adapter
│   ├── vercel.json               ← redirects + headers intégraux
│   ├── src/
│   │   ├── pages/                ← routing file-based
│   │   ├── content/              ← posts/fr, posts/en, authors, categories
│   │   ├── layouts/              ← BaseLayout
│   │   ├── components/           ← seo, layout, content, mdx, sections, analytics, ui
│   │   ├── lib/                  ← json-ld (port), rss (port), sitemap-data, metadata, content, reading-time
│   │   ├── i18n/                 ← dict/{fr,en}.json + utils
│   │   ├── config/               ← site, authors, services, nav, faq, categories
│   │   ├── middleware.ts         ← security headers (déjà template)
│   │   └── styles/global.css
│   └── public/                   ← IndexNow, images blog, favicons, og-default
└── (code Next.js actuel intact)  ← rollback facile
```

---

## Liens

- [Plan complet](~/.claude/plans/zippy-meandering-peach.md)
- [Astro migration guide Next.js](https://docs.astro.build/en/guides/migrate-to-astro/from-nextjs/)
- [Astro content collections](https://docs.astro.build/en/guides/content-collections/)
- [Astro MDX integration](https://docs.astro.build/en/guides/integrations-guide/mdx/)
- [Astro i18n](https://docs.astro.build/en/guides/internationalization/)
- [Astro endpoints (RSS, sitemap)](https://docs.astro.build/en/guides/endpoints/)
- [@astrojs/vercel adapter](https://docs.astro.build/en/guides/integrations-guide/vercel/)
- Note pattern i18n localisé : `Astro-i18n-localized-slugs.md`
- Note OpenPanel : `OpenPanel.md`
- Note GSC rich results : `GSC Rich Results - ElevaSEO.md`

---

## Progression

**Principe : phases courtes validées une par une.**

- [x] Phase 0 — Setup : branche + copie template + doc Obsidian
- [ ] Phase 1 — `src/config/site.ts` (SEO root, org, socials, nav, categories) + `authors/bastien.json` (+ avatar) + schema Zod `content/config.ts` calibré sur le frontmatter Next.js actuel
- [ ] Phase 2 — Config i18n FR default + `i18n/utils.ts` + `i18n/dict/{fr,en}.json`
- [ ] Phase 3 — Script `scripts/migrate-mdx-frontmatter.ts` + migration des 290 MDX
- [ ] Phase 4 — Port `lib/json-ld.ts` (buildBlogPostingJsonLd + Organization + WebSite + SiteNavigationElement + ProfilePage + BreadcrumbList + HowTo)
- [ ] Phase 5 — Port `sitemap.xml.ts` depuis `app/sitemap.ts`
- [ ] Phase 6 — Port `blog/rss.xml.ts` + `en/blog/rss.xml.ts` depuis `lib/rss.ts`
- [ ] Phase 7 — Port `robots.txt.ts` depuis `app/robots.ts`
- [ ] Phase 8 — Port `vercel.json` redirects (`next.config.js` + `lib/blog-redirects.js`) + headers
- [ ] Phase 9 — BaseLayout, Header, Footer, LanguageSwitcher (design existant template, à restyler plus tard)
- [ ] Phase 10 — Page article `blog/[category]/[...slug].astro` + composants MDX custom (HowTo, FAQ, BlogImage, CopyButton, charts recharts en islands)
- [ ] Phase 11 — Blog hub + category
- [ ] Phase 12 — Pages home + services + services enfants + audits + contact + about + legal
- [ ] Phase 13 — API contact + Cal.com
- [ ] Phase 14 — Search + internal linking + analytics (OpenPanel + GA4) + IndexNow
- [ ] Phase 15 — QA + Vercel preview + Lighthouse
- [ ] Phase 16 — Design refonte (hors scope — brainstorming séparé)
- [ ] Phase 17 — Bascule prod + rollback plan
