jules@halfnil:~$ cat ~/blog/newsletter-redirection-localhost.md

Pourquoi ma newsletter renvoyait vers localhost.

Derrière nginx, ma newsletter construisait ses redirections avec l’adresse interne du serveur. J’ai dû séparer clairement origine publique et origine technique.

8 août 2026 · 4 min ·

Le 28 juillet, la newsletter de mon portfolio avait tout ce qu’il fallait sur le papier : inscription, lien de confirmation, désabonnement et gestion des abonnés depuis l’admin.

Le mail arrivait. Le token était présent. La route de confirmation fonctionnait.

Puis le clic renvoyait vers localhost:3004.

Le problème ne venait pas de la newsletter elle-même. Il venait d’une confusion plus basse dans la pile : pour mon application, il n’existait qu’une seule notion d’« origine ». En production, j’en avais en réalité deux.

## Une URL correcte depuis le mauvais point de vue

Le portfolio tourne avec Next.js sur mon VPS. Le processus écoute en interne sur le port 3004, derrière nginx. Depuis l’extérieur, le site est accessible sur https://portfolio.halfnil.fr. Depuis la machine, certains appels peuvent directement viser http://127.0.0.1:3004.

Les deux adresses sont valides. Elles ne servent simplement pas au même endroit.

Mes routes de confirmation et de désabonnement construisaient leur redirection à partir de req.nextUrl.origin. La propriété `nextUrl` de `NextRequest` représente l’URL reçue par l’application. Dans mon déploiement, cette URL était celle vue derrière le reverse proxy : localhost:3004.

Le code faisait donc exactement ce que je lui demandais. Il reprenait l’origine de la requête et ajoutait le chemin de confirmation. Le résultat était cohérent pour le processus Next.js, mais inutilisable dans le navigateur de la personne qui cliquait depuis son mail.

nginx permet de redéfinir les en-têtes transmis au serveur avec `proxy_set_header`. Mais même avec un proxy correctement configuré, baser une URL publique envoyée par email sur le contexte d’une requête interne restait une dépendance que je ne voulais plus laisser implicite.

## Une variable faisait deux métiers

J’avais déjà une variable SITE_ORIGIN. Le nom semblait assez général pour régler le problème.

Sauf qu’elle servait aussi au cron qui génère les articles et demande ensuite une revalidation du site. Pour cet appel exécuté directement sur le VPS, l’origine interne est justement la bonne : inutile de sortir vers le domaine public pour revenir sur la même machine.

J’avais donc deux besoins contradictoires derrière une seule variable :

  • une origine interne pour les appels techniques entre processus sur le VPS ;
  • une origine publique pour les liens qui quittent le serveur, notamment dans les emails.

Changer simplement SITE_ORIGIN vers le domaine public aurait réparé les mails, mais aurait mélangé encore davantage les responsabilités. Garder l’adresse locale préservait le cron, mais continuait d’envoyer des liens cassés.

J’ai séparé les deux usages.

Next.js charge les variables serveur depuis les fichiers .env, comme indiqué dans sa documentation sur les variables d’environnement. Le changement technique était petit. Le changement important était surtout de ne plus considérer « l’URL du site » comme une valeur unique disponible partout.

## Le premier correctif ne suffisait pas

Après avoir séparé l’origine interne de l’origine publique utilisée dans les emails, il restait encore un problème.

Les liens contenus dans les messages étaient maintenant construits avec le bon domaine, mais les routes de confirmation et de désabonnement fabriquaient toujours leur redirection finale avec req.nextUrl.origin.

Le parcours comportait donc deux constructions d’URL différentes :

  • le mail pointait correctement vers le domaine public ;
  • après traitement du token, la route repartait vers l’origine interne.

C’est le genre de correction incomplète qui donne l’impression d’avoir déplacé le bug. L’entrée du parcours était réparée, pas sa sortie.

J’ai donc modifié les deux routes pour qu’elles utilisent elles aussi l’origine publique explicite. L’adresse interne reste réservée aux appels internes. Le domaine public sert dès qu’une URL doit être ouverte hors du serveur.

## Ce que ça donne maintenant

Le cron peut toujours appeler directement l’application sur le VPS. Les emails utilisent le domaine public. Les pages de confirmation et de désabonnement redirigent vers ce même domaine après avoir traité le token.

Je n’ai pas ajouté une abstraction compliquée. J’ai surtout arrêté de faire porter deux sens différents à la même configuration.

Ce chantier m’a laissé une règle assez concrète pour la suite : quand je construis une URL absolue, je dois d’abord savoir qui va l’ouvrir. Un processus local, nginx et un navigateur reçu depuis un email ne voient pas la même application.

La prochaine étape est de repérer les autres endroits du portfolio qui produisent des URL absolues — RSS, images Open Graph, revalidation — et de vérifier qu’ils déclarent eux aussi clairement de quel côté du proxy ils se trouvent.

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