Craft web

Astro 5 + React islands en prod : ce que la doc ne dit pas

Le build du site studio sur Astro 5 avec React islands hydratés sélectivement. Les pièges qui n'apparaissent pas dans le 'getting started', et les patterns qu'on garde.

A Alex Charbonneyre · · 9 min de lecture

TL;DR. Astro 5 + React islands, c’est excellent pour les sites marketing et le contenu. Limite honnête : si votre app est majoritairement interactive (dashboard, SaaS), restez sur un framework full-app (Next.js, TanStack Start). Piège n°1, abuser de client:load et perdre l’avantage SSG. Piège n°2, importer une dépendance lourde dans un layout partagé. Le reste, c’est du confort.

J’ai construit le site moodylabs.studio sur Astro 5 — sections marketing en HTML statique, React hydraté seulement là où il faut (Hero, Navbar, animations au scroll), blog content collections. Voici ce que la doc d’Astro ne dit pas, ou pas assez fort, après avoir mis les mains dedans.

Pourquoi Astro et pas Next.js (cette fois)

Si vous lisez ça pour décider Astro vs Next.js, voici ma réponse honnête. Astro gagne sur trois critères clairs :

  1. Le site est principalement du contenu. Marketing, blog, docs, landing.
  2. Vous voulez 0 JS par défaut, sauf là où vous l’avez explicitement demandé.
  3. Vous mixez React, Vue, Svelte, ou aucun framework du tout.

Astro perd sur trois critères tout aussi clairs :

  1. L’app est un dashboard avec routing client lourd et state global partout.
  2. Vous utilisez React Server Components dans toute votre stack.
  3. Vous voulez ISR ou edge runtime sur chaque route. Astro le fait, mais avec friction.

Pour moodylabs.studio, qui est un site marketing plus blog, Astro était le bon choix : la majorité des pages n’ont aucune raison d’embarquer du JS, et le modèle d’hydratation sélective payait directement sur le poids du bundle initial.

À l’inverse, sur Bulkmark — un SaaS avec session, dashboard et flux interactifs partout — j’ai choisi TanStack Start, pas Astro. Pas par dogme. Parce que sur un produit où chaque page nécessite du JS, l’argument “0 JS par défaut” disparaît, et vous payez la friction d’Astro (deux modèles de composants, deux mental models pour le routing) sans en récolter le bénéfice.

Le bon outil dépend du ratio contenu / interactivité. Beaucoup de contenu, Astro. Beaucoup d’interactivité, framework full-app.

Le modèle islands en 60 secondes

Une page Astro est par défaut du HTML statique. Pas un seul kilo-octet de JS envoyé au client. Quand un composant a besoin de devenir interactif, vous l’enveloppez dans une directive d’hydratation.

---
// src/pages/index.astro
import { HeroSection } from '../app/sections/HeroSection'
import { ServicesSection } from '../app/sections/ServicesSection'
import { Footer } from '../app/sections/Footer'
---
<HeroSection client:load />         {/* hydraté immédiat */}
<ServicesSection client:visible />  {/* hydraté au scroll */}
<Footer />                          {/* jamais hydraté, HTML pur */}

Trois comportements, trois coûts.

client:load ajoute le JS du composant au bundle initial. À utiliser uniquement quand l’interactivité doit fonctionner avant que l’utilisateur scrolle. Sur moodylabs.studio, le Hero contient une nav mobile avec useState, donc oui, client:load.

client:visible lazy-charge le JS via un IntersectionObserver quand le composant entre dans le viewport. C’est le sweet spot pour tout ce qui anime au scroll avec framer-motion.

Sans directive, le composant est rendu en HTML statique au build. Zéro JS envoyé. Les crawlers (Google, Perplexity, ChatGPT, Claude) reçoivent du HTML complet à crawl time, ce qui résout une bonne partie des problèmes de SEO et de citation par les LLM.

Le piège classique : poser client:load partout “au cas où”. Vous tuez l’avantage SSG. La règle pratique est simple : pas de directive sauf si le composant a useState, useEffect, ou un handler qui doit fonctionner avant le scroll. Tout le reste reste en HTML.

Ce que la doc ne dit pas (assez)

Cinq points qui m’ont coûté du temps. Ils ne sont pas cachés, ils sont juste sous-documentés.

1. La frontière des modules partagés est mal documentée. Si un composant client:visible importe framer-motion, et qu’un client:load l’importe aussi, vous pouvez vous retrouver avec framer-motion dans deux chunks distincts au lieu d’un module partagé. Vite finit souvent par dédupliquer, mais pas toujours, surtout si vos imports passent par des barrel files différents. Solution :

// astro.config.mjs
export default defineConfig({
  vite: {
    optimizeDeps: {
      include: ['framer-motion', 'lucide-react']
    }
  }
})

Et un re-export centralisé dans un fichier dédié plutôt que d’importer framer-motion directement dans chaque section.

2. Les anchor links cassent en DEV mais pas en prod. En astro dev, cliquer sur #about ne scrolle pas toujours vers la section après une navigation interne. C’est un comportement connu lié au HMR qui injecte des nœuds après le mount. Disparaît en build statique. Si vous le voyez, ne passez pas une heure à débugger. pnpm build && pnpm preview, vérifiez sur le bundle de prod.

3. getStaticPaths + getCollection : le pattern qui prend 30 minutes la première fois. La doc le montre, mais éclatée sur plusieurs pages. Voici la version condensée, telle qu’utilisée pour le blog Moody Labs :

// src/pages/blog/[...slug].astro
import { getCollection } from 'astro:content'

export async function getStaticPaths() {
  const posts = await getCollection('blog', ({ data }) => !data.draft)
  return posts.map((entry) => ({
    params: { slug: entry.slug },
    props: { entry }
  }))
}

const { entry } = Astro.props
const { Content } = await entry.render()

Trois choses à retenir. Le filtre !data.draft se fait dans getCollection, pas dans le .map. Le slug est inféré du nom de fichier par défaut. entry.render() retourne un composant <Content /> à monter dans le template Astro — c’est l’API qui marche avec les collections de type 'content'. Astro 5 introduit aussi une fonction render(entry) importable depuis astro:content, utile si vous passez aux loaders.

4. Mapbox lazy en Astro, c’est un pattern obligatoire. Le chunk Mapbox GL bundlé pèse autour de 1,8 MB (≈500 KB gzippé). L’importer statiquement dans un composant React détruit votre Lighthouse, même si le composant est client:visible — parce que le bundle est analysé au build, pas à l’exécution. Le pattern qui marche :

import { useEffect, useRef } from 'react'

export function MapboxMonaco() {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!ref.current) return
    const observer = new IntersectionObserver(async ([entry]) => {
      if (!entry.isIntersecting) return
      const mapboxgl = (await import('mapbox-gl')).default
      mapboxgl.accessToken = import.meta.env.PUBLIC_MAPBOX_TOKEN
      new mapboxgl.Map({
        container: ref.current!,
        style: 'mapbox://styles/mapbox/light-v11',
        center: [7.4246, 43.7384],
        zoom: 13
      })
      observer.disconnect()
    })
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [])

  return <div ref={ref} className="h-full w-full" />
}

client:idle ou client:visible côté Astro, plus IntersectionObserver plus await import() côté React. C’est verbeux, c’est obligatoire — sinon le chunk Mapbox entre dans le graphe statique et le bundle initial explose.

5. Les content collections et le schéma image() ont des limites à connaître. Si vous déclarez ogImage: image().optional() dans le schéma Zod d’une collection, l’image doit être référencée par un chemin local résolvable au build (depuis le MDX ou depuis src/assets). Hébergement sur un CDN externe ? Il faut passer par z.string().url() et gérer l’image manuellement côté template. Astro 5 améliore la situation, mais le pattern “image dans le frontmatter, hébergée sur Cloudflare R2 ou S3” demande encore du glue code.

Bonus : <ClientRouter /> a remplacé <ViewTransitions />

Petit piège pour qui migre depuis Astro 4 : le composant <ViewTransitions /> qui activait les transitions de vue a été renommé <ClientRouter /> en Astro 5. Mêmes directives (transition:name, transition:animate, transition:persist), nouveau nom. La doc Astro note d’ailleurs qu’à mesure que les standards web évoluent, <ClientRouter /> deviendra progressivement moins nécessaire (les CSS View Transitions natives prennent le relais).

Patterns Lighthouse : six décisions qui se cumulent

Pas de hack, pas de désactivation de feature. Six décisions architecturales qui, mises bout à bout, font la différence en perf perçue.

  1. Préchargement des fonts en <link rel="preload" as="style"> dans le <head> d’Astro. Évite le flash de texte et améliore le First Contentful Paint sur les pages riches en typo (Inter + Instrument Serif sur le studio).
  2. Hero video en preload="none" sur mobile, source désactivée sous une certaine largeur via media query. Économie majeure sur le trafic mobile et le LCP — une vidéo de fond consomme cher pour ce qu’elle apporte sur un petit écran.
  3. Zéro image stock, mockups SVG et HTML custom. Un mockup Tailwind pèse quelques KB de HTML. Une photo Unsplash bien compressée, 80 à 200 KB. Multipliez par six sections, vous avez votre première seconde de LCP.
  4. Tailwind avec un reset minimal écrit à la main plutôt que les base styles complets. Vous économisez ce dont vous n’avez pas besoin. Note : @astrojs/tailwind est désormais déprécié au profit du plugin Vite natif de Tailwind 4 — le concept du reset minimal reste valide, juste à appliquer dans la nouvelle config.
  5. Mapbox lazy via IntersectionObserver. Voir plus haut. Sans ce pattern, le chunk Mapbox (≈500 KB gzippé) entre dans le bundle statique et tue le score.
  6. JSON-LD inline dans le <head>, pas de fetch externe pour la donnée structurée. Schema.org rendu au build, lisible par les crawlers à crawl time, zéro coût runtime.

Ce qui est tractable, c’est l’ordre de grandeur : sur le build du studio actuel, le bundle initial gzippé sur la home tourne autour de 45-55 KB JS (React runtime + HeroSection + dépendances minimales), le reste des sections étant lazy chargé au scroll. Le chunk Mapbox, lui, ne pèse jamais sur l’initial parce qu’il est dynamiquement importé.

Quand Astro ne suffit plus

Trois cas où vous regretterez Astro.

Router client SPA-like avec state global. Astro fait des transitions de vue (<ClientRouter />), c’est joli, mais ce n’est pas un router client React avec store partagé qui survit à la navigation. Si vous avez Zustand ou Redux qui doit persister entre routes sans hydratation complète, prenez Next, Remix ou TanStack Start.

ISR par utilisateur. Astro a l’on-demand rendering via adapters, mais le DX est verbeux comparé à Next. Si chaque utilisateur voit une version cachée différente d’une route, ce n’est pas le terrain naturel d’Astro.

React Server Components partout. Astro a son propre modèle d’hydratation. RSC dans un projet Astro, c’est mélanger deux paradigmes qui ne se parlent pas. Choisissez l’un ou l’autre.

FAQ

Faut-il abandonner Next.js pour Astro ? Non. Gardez Next (ou TanStack Start, ou Remix) pour vos SaaS et dashboards. Migrez vers Astro vos sites marketing, blogs et docs. La décision se fait par projet, pas par stack.

Astro est-il prêt pour la prod en 2026 ? Oui, sans réserve sur le SSG. Sur l’on-demand rendering et les adapters serverless, c’est solide mais moins mature que Next. Pour un site statique ou hybride léger, c’est prêt depuis Astro 4.

Quelles dépendances React posent problème dans les islands ? Tout ce qui touche au DOM au moment du module load. Certaines libs de charts qui s’attendent à window, les anciennes versions de react-router qui supposent un Provider racine, les modales qui injectent du portal au mount. Lazy-chargez via await import() dans un useEffect, ou wrappez dans un composant client:only="react".

Pour aller plus loin

Pour citer cet article : Charbonneyre, A. (2026). Astro 5 + React islands en prod : ce que la doc ne dit pas. Moody Labs.

Un projet en tête ? Un appel court suffit pour voir si ça matche.