Vinicius Aguiar
Architecture

La page faisait 514 lignes. Le cadre, 2 560.

25 septembre 2026 · 6 min de lecture

Dans mon dernier article, j'ai mis une app Angular à l'intérieur d'une page de ce site : un micro frontend composé dans la page. C'est un modèle. Un autre est plus simple à décrire : le découpage par route. Un chemin du site appartient à une autre app, avec son propre build et son propre déploiement, et le domaine reste le même.

Alors je l'ai fait ici. La page Outils est désormais servie par une deuxième app Next.js. Ouvrez les DevTools dessus : la réponse porte un en-tête que ce site n'envoie nulle part ailleurs, x-zone: uses.

La page était la partie bon marché

La page elle-même fait 514 lignes : une liste d'outils en cinq langues. La déplacer dans une autre app devrait être un copier-coller.

Ce n'en est pas un, parce que la page ne vit pas seule. Elle est dans la barre latérale, le header mobile, le footer, le raccourci de recherche, les sélecteurs de langue et de thème, les tokens de design et les traductions. Compté, ce cadre fait environ 2 560 lignes, cinq fois la page. Un micro frontend par route n'a pas besoin d'une page ; il a besoin de tout le cadre autour, identique, dans deux apps.

C'est là qu'était la vraie décision. Copier le cadre, c'est deux barres latérales qui divergent avec le temps. Donner à la nouvelle app un cadre minimal, c'est une page qui ressemble à un autre site. La réponse qui passe à l'échelle, c'est un monorepo avec le cadre en package partagé.

Étape un : un monorepo qui ne change rien

Avant de créer la moindre nouvelle app, j'ai transformé le dépôt en monorepo pnpm et Turborepo : le site est passé dans apps/portfolio, et le cadre, le design system et les traductions sont devenus des packages. La règle : les visiteurs ne devaient rien remarquer.

« Identique » devait être mesuré, pas supposé. Avant de déplacer le moindre fichier, j'ai sauvegardé le HTML des 122 pages et l'ensemble complet des sélecteurs CSS, puis je les ai comparés après chaque étape. Cette comparaison a servi deux fois :

  • 58 classes CSS ont disparu. Tailwind scannait tout le dépôt, y compris les exemples de code de la documentation, et générait du CSS pour eux. En ne scannant plus que l'app, ce CSS mort est parti. J'ai vérifié chacune avant de l'accepter.
  • Le cadre a perdu ses styles quand j'ai retiré une ligne. Tailwind ne génère que les classes qu'il trouve, et les packages vivent hors de l'app. Sans un @source explicite qui pointe vers eux, la barre latérale s'affiche sans style et tous les checks restent verts.

Une revue indépendante en a ensuite trouvé un de plus : Turborepo ne savait pas que l'app dépendait des packages, donc un changement dans le seul cadre réutilisait les résultats de tests en cache de l'app. La CI aurait laissé passer une traduction manquante. Une règle de dépendance a réglé le problème.

Étape deux : la zone

Avec le cadre en package, la nouvelle app est petite. Elle sert une route, utilise le même cadre et préfixe ses assets pour qu'ils n'entrent pas en collision avec ceux du portfolio sur le même domaine :

const nextConfig: NextConfig = {
  // The zone's assets must not collide with the portfolio's on the same domain.
  assetPrefix: "/uses-static",
  transpilePackages: ["@repo/i18n", "@repo/ui", "@repo/shell"],
  async headers() {
    return [{ source: "/(.*)", headers: [{ key: "x-zone", value: "uses" }] }]
  },
}

Le portfolio lui transmet la route. Si l'URL de la zone manque dans un build de production, le build échoue au lieu de publier un rewrite vers nulle part :

export function usesRewrites(env: { USES_ZONE_URL?: string; NODE_ENV?: string }) {
  const raw = env.USES_ZONE_URL ?? (env.NODE_ENV === "production" ? undefined : "http://localhost:3001")
  if (!raw) throw new Error("USES_ZONE_URL is required in production builds")
  const zone = raw.replace(/\/$/, "")
  return [
    { source: `/${USES_LOCALE_PARAM}/uses`, destination: `${zone}/:locale/uses` },
    { source: `/${USES_LOCALE_PARAM}/uses/:path*`, destination: `${zone}/:locale/uses/:path*` },
    { source: "/uses", destination: `${zone}/uses` },
    { source: "/uses-static/:path*", destination: `${zone}/uses-static/:path*` },
  ]
}

Le domaine propre de la zone sert un robots.txt qui bloque tout. Les moteurs de recherche arrivent sur la page par le domaine principal, et l'URL canonique y pointe déjà.

Un lien qui sait où il va

La partie subtile, c'était la navigation. La navigation côté client ne fonctionne qu'à l'intérieur d'une même app : depuis la page Outils, aller à l'accueil avec une route côté client demanderait cette route à une app qui ne l'a pas. Le cadre a donc appris à quelle app appartient chaque chemin :

export type Zone = "portfolio" | "uses"

const LOCALE_GROUP = `(?:${LOCALES.join("|")})`
const USES = new RegExp(`^(?:/${LOCALE_GROUP})?/uses(?:/.*)?$`)

export function zoneFor(pathname: string): Zone {
  return USES.test(pathname) ? "uses" : "portfolio"
}

export function linkMode(current: Zone, href: string): "client" | "document" | "external" {
  if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith("//")) return "external"
  const pathname = href.split(/[?#]/)[0] || "/"
  return zoneFor(pathname) === current ? "client" : "document"
}

Chaque lien et chaque navigation programmatique du cadre passent par cette décision. Changer de langue dans Outils reste côté client. Aller d'Outils à l'accueil, ou à la recherche, charge un nouveau document, volontairement.

Ce que ça a coûté

  • Le saut supplémentaire. Servie directement, la zone répondait en 224 ms ; via le rewrite du portfolio, en 267 ms.
  • Le côté client n'est pas automatiquement plus rapide. Changer de langue dans la zone (côté client) a pris 534 ms, alors que passer de la zone à l'accueil (document complet) a pris 318 ms. La transition côté client va chercher les données de la page à travers le rewrite : un aller-retour de plus à chaque changement.
  • Les déploiements sont vraiment indépendants. Un commit dans la seule zone ne rebuilde que la zone ; un commit dans le seul portfolio ne rebuilde que le portfolio ; un changement dans le cadre rebuilde les deux, parce que les deux en dépendent.
  • La panne a une autre forme. Si la zone tombe, /uses échouerait et le reste du site continuerait de fonctionner : un rewrite n'a pas de repli. Dans l'expérience Angular, un micro frontend en panne faisait tomber un bloc et laissait la page debout.

Ce que seule la production a montré

Tout passait en local. Deux choses ne sont apparues qu'avec les deux apps déployées :

  • Les prefetchs échouaient à travers le rewrite. Next.js 16 précharge une route segment par segment, avec un en-tête spécial. Appelée directement, la zone répondait à ces requêtes. Via le portfolio, la même requête traversait le rewrite et arrivait à la zone sous une forme qu'elle ne savait pas servir : 404. Les requêtes complètes de la page passaient normalement ; seul le préchargement cassait. La navigation continuait de fonctionner, simplement sans préchargement. Ma première correction, déplacer le rewrite vers la couche de la plateforme (vercel.json), n'a rien changé. Ce qui a marché, c'est de désactiver le préchargement des liens à l'intérieur de la zone : la requête qui échouait a tout simplement cessé d'exister.
  • La réponse de revalidation arrivait sans en-têtes. Dans l'expérience Angular, le panneau affichait 0,3 Ko comme coût du module pour les visiteurs qui revenaient. Le 304 que la plateforme renvoie à la revalidation ne porte aucun des en-têtes CORS et de timing, donc le navigateur ne rapporte que la taille des en-têtes. Le panneau traite désormais ce cas comme du cache.

Ce que le produit géré aurait fait

Vercel a un produit Microfrontends qui fait ce routage à l'edge, à partir d'un seul fichier de configuration, avec du préchargement entre zones et un proxy local. Je l'ai fait avec de simples rewrites Next.js pour qu'une expérience ne dépende pas d'un produit qui pouvait être payant. La différence, c'est qui opère la tuyauterie, pas si le modèle fonctionne.

Un dépôt, deux propriétaires

Une question légitime : la nouvelle app ne devrait-elle pas vivre dans son propre dépôt ? Les micro frontends, c'est une affaire de déploiements et de propriétaires indépendants, pas de dépôts. Ici, la zone a sa propre app, son propre projet Vercel et son propre déploiement, et un fichier CODEOWNERS rend la responsabilité explicite. Un dépôt séparé m'aurait obligé à publier le cadre en package versionné et à garder deux apps synchronisées avec lui, exactement la divergence que je voulais éviter. L'expérience Angular a eu son propre dépôt, parce qu'elle ne partageait rien avec le site.

Quand ça n'en vaut pas la peine

Pour une personne et une page, c'est excessif, et c'était tout l'intérêt. Le coût n'est pas la page : c'est le cadre, le monorepo, les rewrites et un nouveau mode de panne. Ça commence à payer quand plusieurs équipes doivent publier des parties du même site à leur propre rythme. Maintenant, je sais à quoi ressemble la première étape.