Migrer un site Remix v2 vers Next.js 16 : retour d'expérience
Ce que j'ai réellement dû réécrire en migrant ce portfolio de Remix v2 vers Next.js 16 : loaders, actions, métadonnées, et les pièges du build.
- next-js
- remix
- migration

Ce site tournait sous Remix v2. Il tourne aujourd'hui sous Next.js 16 avec l'App Router, et la migration a été moins une traduction ligne à ligne qu'un changement de modèle mental : dans Remix, on charge des données puis on rend un composant client ; dans l'App Router, le composant est le chargement de données. Une fois ce basculement admis, la plus grande partie du travail devient mécanique. C'est le reste — métadonnées, en-têtes, sortie de build — qui prend du temps.
Voici ce que j'ai fait, dans l'ordre, et ce que je referais autrement.
Pourquoi migrer, et pourquoi ce n'est pas toujours justifié
Commençons par la question honnête : un site Remix v2 qui fonctionne n'a pas besoin d'être migré. Remix reste un excellent framework, et « la nouveauté » n'est pas un argument technique.
Mes raisons étaient concrètes. Je voulais les images Open Graph générées à la volée par next/og, la génération statique par route avec generateStaticParams, et la convention metadata qui remplace les balises écrites à la main. Je voulais aussi que mon portfolio parle la même langue que la majorité des projets sur lesquels j'interviens : quand on est freelance, l'outil qu'on pratique tous les jours a une valeur en soi.
Si vous n'avez aucun de ces besoins, gardez Remix. Si vous en avez plusieurs, lisez la suite.
Étape 1 : repartir d'un projet neuf, pas d'une conversion sur place
La tentation est d'installer Next dans le dépôt existant et de convertir les fichiers un par un. J'ai fait l'inverse : j'ai généré un projet Next neuf, puis j'ai réintégré l'ancien code morceau par morceau.
L'avantage est net. Les fondations — tsconfig, alias de chemins, configuration Tailwind, ESLint, Dockerfile — sont générées correctement du premier coup, sans résidus de Vite ni de dépendances Remix orphelines. Ensuite, chaque fichier réintégré est un fichier que j'ai relu. Rien ne passe en douce.
Concrètement, mon premier commit de migration contenait uniquement le squelette : configuration, layout.tsx, une page d'accueil vide, la sortie standalone et le Dockerfile. Le site ne faisait rien, mais il buildait. Tout le reste est venu se brancher dessus.
Étape 2 : les loaders deviennent des composants serveur
C'est le cœur de la migration, et la bonne nouvelle est que ça simplifie le code.
Dans Remix, une route exporte un loader qui renvoie des données, et le composant les récupère avec useLoaderData. Deux fonctions, un contrat sérialisable entre les deux, et des types à faire circuler.
Dans l'App Router, le composant de page est asynchrone et s'exécute sur le serveur. Il lit ses données directement dans son corps. Le loader et le useLoaderData disparaissent tous les deux : il ne reste que la lecture.
En pratique, chaque route Remix s'est décomposée en deux questions. Est-ce que ce composant a besoin d'interactivité — état, effets, écouteurs d'événements ? Si non, il reste serveur et n'envoie aucun JavaScript au navigateur. Si oui, il devient un composant client marqué "use client", et je pousse cette directive le plus bas possible dans l'arbre.
C'est le principal gain de la migration. Sur ce site, l'en-tête avec son menu mobile est client, le formulaire de contact est client, les animations sont client. Tout le reste — les listes de projets, la frise du parcours, la stack technique — est du HTML rendu sur le serveur. Le premier chargement n'a plus rien à hydrater.
Étape 3 : les actions deviennent des Server Actions
Mon formulaire de contact était une action Remix : le formulaire postait vers sa propre route, l'action validait, envoyait le mail, renvoyait les erreurs.
La transposition est directe. Un fichier marqué "use server" exporte une fonction asynchrone, le formulaire l'appelle, la validation reste exactement la même — j'ai recopié mon schéma zod tel quel. Le retour d'erreurs par champ, que je gérais avec le retour de l'action, se gère avec l'état du formulaire côté client.
Un détail m'a arrêté quelques minutes : un fichier "use server" ne peut exporter que des fonctions asynchrones. Mon schéma de validation, que j'exportais par réflexe pour le réutiliser dans les tests, provoquait une erreur de build. Il est redevenu une constante interne au module. C'est le genre de contrainte qui semble arbitraire jusqu'à ce qu'on se rappelle que tout ce qui est exporté depuis ce fichier devient un point d'entrée appelable depuis le réseau.
Étape 4 : les métadonnées, le vrai gain
Dans Remix, chaque route exporte une fonction meta qui renvoie un tableau de descripteurs de balises. Ça marche, mais on écrit ses balises Open Graph et Twitter à la main, et rien ne vous prévient quand vous en oubliez une.
Next remplace ça par un objet metadata exporté par la page — ou une fonction generateMetadata quand le contenu est dynamique. Le titre, la description, le canonical, l'Open Graph, la carte Twitter : tout est typé. Une faute de frappe dans un nom de propriété devient une erreur de compilation au lieu d'une balise silencieusement absente.
Deux conventions m'ont fait gagner un temps considérable :
app/sitemap.tsremplace une route qui générait du XML à la main. On exporte une fonction qui renvoie un tableau d'entrées typées ; Next sert/sitemap.xml.opengraph-image.tsxgénère l'image de partage de chaque page à partir d'un composant React, vianext/og. J'ai un gabarit commun — papier, grille, titre, sous-titre manuscrit — et chaque page l'appelle avec ses propres textes. Plus aucune image de partage à produire dans un outil de design.
Sur le lastmod du sitemap, un choix contre-intuitif : je l'écris à la main plutôt que d'utiliser new Date(). Une date qui change à chaque build ne dit rien à un moteur de recherche, sinon que le site se redéploie. Une date qui ne bouge que lorsque le contenu bouge est un vrai signal. Depuis que j'ai ajouté le blog, les articles apportent leur propre date de mise à jour via leur frontmatter — c'est le seul endroit où l'automatisme est justifié, parce que la date suit alors réellement le contenu.
Étape 5 : les pièges que je n'avais pas vus venir
La sortie standalone ne contient pas vos fichiers
output: "standalone" produit un serveur Node minimal avec seulement les dépendances réellement utilisées. C'est excellent pour une image Docker. Mais la trace de fichiers qui décide de ce qui est embarqué suit les imports, pas les lectures de disque dynamiques.
Traduction : si votre code fait un fs.readFileSync sur un dossier de contenu, ce dossier peut très bien ne pas se retrouver dans la sortie. Le site fonctionne en local, se construit sans erreur, et échoue en production. La parade est outputFileTracingIncludes, qui déclare explicitement les fichiers à embarquer par route.
Même famille de piège : le serveur standalone ne sert pas les fichiers statiques. Il faut copier .next/static et public/ à côté de server.js. Le Dockerfile le fait, mais si vous lancez node .next/standalone/server.js à la main pour tester, vous obtenez un site sans style et vous cherchez au mauvais endroit.
L'en-tête Vary disparaît sur les pages
Celui-là m'a coûté une soirée : un en-tête Vary posé sur les pages via headers() ou via le proxy ne survit pas jusqu'à la réponse, alors qu'il tient parfaitement sur les route handlers. Le sujet mérite son propre article, je l'ai écrit ici.
Les requêtes de navigation ne sont pas des requêtes normales
Si vous écrivez un proxy qui inspecte l'en-tête Accept — pour de la négociation de contenu, par exemple — attention aux navigations internes de l'App Router. Elles ne demandent pas du HTML, elles demandent une charge utile React Server Components, avec ses propres en-têtes. Un proxy qui les traite comme des requêtes de document casse la navigation côté client de façon parfaitement déroutante : le premier chargement fonctionne, les clics ne font rien.
La règle que j'applique : détecter ces requêtes en premier et les laisser passer sans y toucher, avant toute autre logique.
Ce que je ferais différemment
Migrer les tests d'abord. J'ai porté les composants avant d'avoir un filet. La logique pure — négociation de contenu, rendu Markdown, sérialisation — se teste sans Next et sans navigateur ; c'est exactement ce qu'il fallait mettre en place au premier jour.
Ne pas mélanger migration et refonte. J'ai eu la tentation de redessiner en même temps que je migrais. Quand quelque chose casse, on ne sait plus si c'est le framework ou le nouveau composant. Une chose à la fois.
Accepter que certains fichiers restent moches un moment. Un portage fidèle qui fonctionne vaut mieux qu'une réécriture élégante à moitié finie. Le nettoyage vient après, avec les tests en place.
En résumé
La migration Remix → Next se joue sur trois blocs : les loaders qui deviennent des composants serveur, les actions qui deviennent des Server Actions, et les métadonnées qui passent d'un tableau écrit à la main à des conventions typées. C'est la partie prévisible, et elle se passe bien.
Le temps réellement consommé est ailleurs : la sortie de build, les en-têtes, les requêtes de navigation, tout ce qui n'apparaît que sur un vrai déploiement. Prévoyez-le.
Le code de ce site est le produit de cette migration — vous pouvez en voir le résultat côté projets, et sa couche technique pour les agents côté développeurs. Si vous avez un projet Remix à migrer et que vous voulez en discuter, écrivez-moi.

