jules@halfnil:~$ cat ~/blog/uploads-nextjs-404-apres-demarrage.md

Mes uploads existaient, mais Next.js répondait 404.

Les couvertures créées après le démarrage du portfolio existaient bien sur le VPS, mais restaient invisibles jusqu’au redémarrage de Next.js.

30 juillet 2026 · 3 min ·

Le 28 juillet, j’ai corrigé un bug assez trompeur sur le portfolio. Une image pouvait être enregistrée correctement sur le VPS, avoir une URL valide en base, puis renvoyer une 404 dans le navigateur.

Le fichier existait. Le serveur tournait. Le reste de l’article s’affichait. Mais pas sa couverture.

Un redémarrage de l’application suffisait à la faire apparaître.

## Un dossier statique devenu dynamique

Les images du site passent par public/uploads. C’est le cas des fichiers envoyés depuis mon panneau d’admin, mais aussi des couvertures créées par le cron qui publie les articles.

Au départ, ce choix semblait logique. Next.js sert les fichiers placés dans public depuis la racine du domaine. Une image enregistrée dans public/uploads/test.png devient donc accessible sur /uploads/test.png, comme l’explique la documentation du dossier public.

Le problème, c’est le moment où le fichier arrive.

Une image présente avant le démarrage de next start fonctionnait. Une image écrite ensuite restait inconnue du serveur. Une ancienne version de la documentation sur les assets statiques décrit précisément cette limite : les fichiers ajoutés pendant l’exécution ne sont pas disponibles comme les assets présents au build.

J’avais donc construit un système contradictoire : le chemin était statique, mais son contenu était alimenté à l’exécution.

Ce n’était pas visible avec les images déjà présentes dans le dépôt. Le bug est devenu évident quand le blog a commencé à produire et recevoir de nouvelles couvertures sans nouveau déploiement.

## Le faux diagnostic du cache

Ma première difficulté a été de situer le problème.

Comme le portfolio utilise maintenant l’ISR pour ses pages publiques, une image absente pouvait facilement ressembler à une page périmée. J’avais aussi ajouté de la revalidation après la publication automatique des articles. Il était donc tentant de regarder du côté du cache, du RSS ou des données de l’article.

Mais la page contenait déjà la bonne URL. La base contenait déjà la bonne URL. Et le fichier était bien sur le disque.

La requête directe vers l’image renvoyait simplement 404.

Le redémarrage de pm2 donnait l’indice utile : après le redémarrage, sans modifier la base ni l’article, la même URL répondait correctement. Le souci n’était pas la création du fichier. C’était sa prise en compte par le serveur Next.js déjà lancé.

## Servir un upload comme un upload

La correction a été de ne plus dépendre de la découverte initiale de public pour les fichiers ajoutés après le démarrage.

Les URLs restent sous /uploads. Je n’avais aucune raison de modifier les valeurs déjà stockées en base ni le rendu des articles. En revanche, leur résolution passe maintenant par un traitement à la requête : le serveur cherche le fichier demandé sur le disque et renvoie son contenu immédiatement.

Dans l’App Router, les Route Handlers permettent justement de construire ce type de réponse avec les API Request et Response. Ce n’est plus un asset supposé immuable au lancement. C’est un fichier produit par l’application et servi par elle.

J’ai aussi gardé une séparation stricte entre l’URL demandée et le chemin réel sur le VPS. Une route qui lit un fichier depuis un nom fourni dans l’URL ne doit pas devenir un accès libre au système de fichiers. Le chemin reste limité au dossier des uploads, et une absence réelle retourne toujours une 404.

## Ce que ça donne maintenant

Une couverture créée par le cron est disponible dès la publication de l’article. Une image envoyée depuis l’admin s’affiche sans rebuild et sans redémarrage de pm2. Les anciennes URLs continuent de fonctionner.

Le changement n’est pas spectaculaire dans l’interface. Il retire surtout une dépendance cachée au cycle de vie du serveur.

C’est le genre de bug que je rencontre en traitant mon portfolio comme un produit complet. L’upload fonctionnait côté formulaire. L’écriture fonctionnait côté Node. L’article fonctionnait côté Next.js. Mais l’ensemble ne fonctionnait pas tant que je considérais encore un fichier créé à l’exécution comme un simple asset statique.

Le test de vérité tient maintenant en deux actions : envoyer une image depuis l’admin, puis ouvrir son URL sans toucher à pm2. Si elle répond 200 immédiatement, le chemin complet fonctionne. Pas seulement le bouton d’upload.

Image de couverture générée par IA — faute d'illustration officielle disponible pour ce sujet.