Next.js 16 est une bonne version, je tiens à le dire avant de passer le reste de l'article à râler dessus. L'App Router a mûri, Turbopack est devenu le défaut, les temps de build ont fondu. Sur le papier, tout va bien. Et pourtant, en redéployant ce portfolio cette semaine (Next 16, self-hosted, image Docker, pas de Vercel pour amortir les angles), je me suis pris quatre murs que les tutos ne montrent jamais. Leur point commun, c'est qu'aucun ne déclenche d'erreur. Le build passe au vert. C'est précisément ça, le problème.
Le middleware s'appelle proxy.ts, maintenant
Premier truc qui déstabilise en arrivant sur Next 16 : le fichier de middleware ne s'appelle plus middleware.ts, il s'appelle proxy.ts. C'est un renommage de convention, pas une option. Si tu débarques avec tes réflexes de Next 14 ou 15, tu cherches un fichier qui n'existe plus.
Le piège n'est pas dans le code, il est dans les outils qui ne sont pas encore au courant. Un audit automatique de mon propre repo me l'a remonté comme un bug : « fichier proxy.ts non standard, à renommer en middleware.ts ». Faux positif intégral. Si je l'avais écouté, je cassais tout le routage i18n du site. La preuve que la convention est la bonne, elle est en ligne : une requête sur / répond par un 307 vers /fr. C'est exactement le boulot du middleware next-intl, et il tourne, dans un fichier nommé proxy.ts.
// src/proxy.ts
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
export default createMiddleware(routing);
export const config = {
matcher: ['/', '/(fr|en)/:path*', '/((?!api|_next|_vercel|docs|.*\\..*).*)'],
};La leçon n'est pas « proxy.ts, c'est bizarre ». C'est plutôt : quand un linter ou un audit te signale une convention de framework comme une anomalie, va vérifier la doc de ta version avant de « corriger ». Sur un changement de nom aussi récent, l'outil a souvent un train de retard sur le framework.
generateStaticParams ne reçoit pas la locale du parent
Le deuxième piège m'a coûté le plus de cervelle, et il est franchement vicieux. Ma route d'article vit dans [locale]/blog/[slug]. Deux segments dynamiques imbriqués : la langue d'abord, le slug ensuite. Pour générer les pages au build, le generateStaticParams de l'enfant a besoin de connaître la locale du segment parent.
Sauf qu'en Next 16, ce params.locale du parent ne remonte pas de façon fiable dans le generateStaticParams de l'enfant. Concrètement, j'appelais ma fonction de listing avec une locale undefined. Et getAllPosts(undefined) ne plante pas, elle retourne poliment un tableau vide. Zéro slug généré. Comme je tiens à du statique pur, j'ai aussi dynamicParams = false, ce qui veut dire « toute URL que je n'ai pas pré-générée n'existe pas ». Tu vois le résultat venir : zéro page pré-générée plus zéro rendu à la volée, ça fait un blog entier en 404.
La parade, c'est d'arrêter d'attendre la locale du parent et de l'énumérer moi-même. Je boucle sur les locales déclarées dans mon routing et je retourne les couples { locale, slug } complets, en toutes lettres.
// src/app/[locale]/blog/[slug]/page.tsx
import { routing } from '@/i18n/routing';
import { getAllPosts } from '@/lib/blog';
export const dynamicParams = false;
export async function generateStaticParams() {
const allParams: { locale: string; slug: string }[] = [];
for (const locale of routing.locales) {
const posts = await getAllPosts(locale);
for (const post of posts) {
allParams.push({ locale, slug: post.slug });
}
}
return allParams;
}Rien de sorcier une fois qu'on a compris. Le détail traître, c'est que le code « naïf » (celui qui fait confiance au params.locale du parent) compile sans broncher. Il ne casse qu'au rendu, et seulement pour de vrai en prod.
Un build vert ne prouve rien
Voilà le vrai sujet de l'article, celui qui englobe les deux précédents. Quand generateStaticParams renvoie un tableau vide, Next ne considère pas ça comme une erreur. Il en déduit qu'il n'y a rien à pré-rendre, et avec dynamicParams = false, il prérend la page en 404. Pas un warning. Pas un exit code à 1. Le pipeline reste tout vert. Même histoire pour n'importe quelle page qui appelle notFound() dans un cas que tu n'avais pas anticipé : elle est figée en 404 au build, en silence.
Je l'ai vécu cette semaine, et c'est rageant. Un article de blog déployé, en ligne, en 404 pur, alors que le next build avait passé tous ses checks et que l'image était déjà partie en prod. Aucun signal nulle part. Tu découvres la page cassée comme un visiteur lambda, en cliquant sur ton propre lien.
C'est contre-intuitif parce qu'on a tous appris à faire confiance au feu vert. Le build compile, donc « ça marche ». Non. Le build te dit que ton code est syntaxiquement valide et que tes types tiennent. Il ne te dit rien de ce que tes pages renvoient réellement quand un humain les demande. Un 404 prérendu, pour le compilateur, c'est une page parfaitement valide. Elle a juste le mauvais contenu.
La parade : tester le serveur qui tourne vraiment
La seule chose qui attrape ces 404 silencieux, c'est de lancer le serveur réel et de regarder ce qu'il répond. Et là, deuxième surprise spécifique au self-hosted : avec output: "standalone", la commande next start ne sert pas ton app. Ce n'est pas un bug, c'est juste que ce n'est plus la bonne commande. Il faut démarrer le serveur standalone à la main, en recopiant .next/static et public à côté comme le fait l'image Docker.
cp -r .next/static .next/standalone/.next/static
cp -r public .next/standalone/public
node .next/standalone/server.js
# puis, dans un autre terminal, on vérifie les pages clés
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/fr/blog/mon-articleTrente secondes, et le 404 te saute aux yeux avant qu'il ne parte en prod. J'en ai fait un petit smoke test que je lance avant chaque déploiement, qui boote le serveur standalone et vérifie au curl que mes URLs importantes répondent bien 200. Ce n'est pas glamour. C'est juste le seul moyen que je connaisse de distinguer « le code compile » de « le site fonctionne ». Pour la mécanique complète du déploiement self-hosted (image standalone, registre, reverse proxy), j'en ai parlé en détail dans mon article sur l'hébergement d'une app Next.js sur un VPS ; ici je voulais surtout pointer ce qu'un build vert te cache.
Deux derniers cailloux dans la chaussure
Tant qu'on parle de Turbopack, il vient avec sa propre subtilité quand tu écris en MDX. Sous Turbopack (le défaut en 16), les plugins remark et rehype ne reçoivent que des options sérialisables. Tu peux leur passer des chaînes, des objets, des tableaux, mais pas une fonction. Si ta config de plugin marchait avec une callback du temps de Webpack, elle va te lâcher sans explication limpide. Il faut repenser la conf en pur déclaratif.
Et un dernier, qui n'est pas propre à Next mais qui mord exactement au même endroit : changer une dépendance sans régénérer yarn.lock. En local, yarn install rattrape l'écart tout seul et tu ne vois rien. En CI et dans le build Docker, j'installe avec --immutable, qui refuse catégoriquement de toucher au lockfile. Le moindre décalage entre package.json et yarn.lock fait échouer l'install, donc le build, donc le déploiement. Encore une fois, ça passe en local et ça casse là où tu ne regardes pas en direct.
Le fil rouge de cette semaine, c'est ça : sur une plateforme managée, une preview par branche t'aurait peut-être collé le 404 sous le nez avant le merge. En self-hosted, personne ne regarde à ta place. La seule chose en qui j'accorde encore ma confiance, ce n'est pas la pastille verte de GitHub, c'est le serveur standalone qui répond 200 sur ma machine avant que l'image ne décolle. Un build qui compile te parle de ton code. Il ne dira jamais un mot de ce que tes visiteurs vont voir.
Si tu déploies du Next.js self-hosted et que tu veux un regard extérieur avant que ça parte en prod, on en parle.
