02Jugement5 min

Je ne pars jamais de zéro : ce que j'emporte toujours et ce que je coupe toujours

Dix projets sur le même boilerplate. Le premier commit de chacun ressemble plus au dernier du précédent qu'à un projet neuf, et le deuxième commit supprime presque toujours quelque chose.

Par Salva Castells

Image de couverture : Park taeho, CC BY-SA 4.0.
Dans cet article

Le premier commit d'un de mes projets s'appelle « Initial commit » et apporte déjà l'auth, la base de données, les paiements, l'i18n et un panneau d'admin. Le deuxième, bien souvent, s'appelle « remove useless things ». Cette paire de commits résume mieux que tout ma façon de travailler : je ne pars pas de zéro, je commence par cloner mon template et couper.

Dix projets comme ça donnent un jugement assez affûté sur ce qui est le noyau et ce qui est du bruit. Ce n'est pas une opinion : c'est dans l'historique, et dans les endroits où la même suppression se répète.

Ce qu'on gagne à commencer ainsi

Un SaaS de gestion d'incidents pour une entreprise de transport est passé de zéro à la production en trois jours. Jour un : cloner et retirer le superflu. Jour deux : schéma des incidents, téléversement d'images, drawer de détail, cache des listes. Jour trois : landing page. Les trois jours ont été intégralement consacrés au domaine. Personne n'a touché à un login.

Un SaaS de génération d'images a démarré pareil. Le jour de l'« Initial commit », il y avait déjà le schéma des images générées, le helper S3, le branding et le thème. Le lendemain, le pipeline de génération était en place. L'auth, Postgres avec Drizzle, Stripe et l'i18n sont arrivés tout faits.

Ce qu'on économise, ce n'est pas d'écrire le login. C'est le mois de petites décisions qu'il traîne derrière lui : où vit la session, comment on protège une nouvelle route, ce qui se passe quand le cookie expire, comment on déclare les langues.

Ce qui reste toujours

  • Une auth avec une seule porte. Pas une fonction requireSession() que chaque page doit penser à appeler, mais un layout.tsx dans l'arbre privé. Une nouvelle page est protégée par l'endroit où vous la placez, pas parce que vous avez pensé à copier la vérification.
  • Postgres avec un ORM typé et des migrations versionnées. Il y a ici un détail qui met du temps à se découvrir : si les snapshots de drizzle se désynchronisent, drizzle-kit generate fait un diff contre une base vide et réémet un CREATE TABLE de tout le schéma. Ça se corrige en reconstruisant le snapshot initial jusqu'à ce que generate dise à nouveau qu'il n'y a pas de changements.
  • L'i18n dès la minute zéro. L'ajouter après oblige à toucher chaque chaîne du projet. L'ajouter avant coûte un après-midi.
  • Un admin minimal. Liste des utilisateurs et quatre métriques. Rien de plus.

Ce qui est toujours coupé

Stripe est le cas évident. Dans une application de pointage, les routes checkout, portal et webhook ont été entièrement supprimées le jour même du commit initial : 338 lignes dehors. Dans un template de visionneuse interne, identique. Deux projets différents, un an d'écart, la même suppression.

La conclusion n'a pas été de retirer Stripe du template, mais de le mettre derrière un drapeau :

export const USE_STRIPE = process.env.USE_STRIPE === "true";

export async function POST(request: NextRequest) {
  if (!USE_STRIPE) {
    return NextResponse.json({ error: "Stripe is not enabled" }, { status: 501 });
  }

Un projet sans paiements met maintenant USE_STRIPE=false et continue. Avec une nuance qui a mis du temps à se découvrir : le client Stripe valait null as unknown as Stripe quand le drapeau était éteint, si bien que la première utilisation négligente donnait une TypeError opaque au lieu de dire ce qui se passait. C'est maintenant un Proxy qui lève une erreur explicite.

L'autre coupe, c'était RLS. Les tables sont nées avec crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) de drizzle-orm/neon. Elles ont toutes été retirées. La raison n'est pas que RLS soit mauvais : c'est que l'application faisait déjà tous ses accès par une connexion admin depuis le serveur, si bien que les politiques n'étaient jamais évaluées et donnaient un sentiment de sécurité inexistant. Deux couches d'autorisation dont une seule est vivante, c'est pire qu'une seule, parce que personne ne sait laquelle commande.

Et l'admin surdimensionné. Le template de l'équipe a accumulé des mois de panneau d'administration et de gestion d'utilisateurs, et a fini dans un commit qui le supprime entièrement. Il transmet son coût à tous les enfants et presque aucun ne l'utilise.

Le coût différé

Voilà ce que personne ne raconte à propos du travail sur template : un bug dans la mère naît déjà cloné chez tous les enfants.

Le plus coûteux que j'aie eu était une ligne dans le root layout : alternates.canonical: BASE_URL. Toute page qui ne surchargeait pas son canonical se canonicalisait vers l'accueil et sortait de l'index. C'est resté là pendant des mois, dans chaque clone. La règle que j'en ai tirée : un canonical absent est inoffensif, un canonical erroné ne l'est pas. Depuis, le layout partagé ne met pas de canonical et chaque route met le sien.

Deux autres du même genre :

  • Le .gitignore n'ignorait que .env.local. Le premier réflexe naturel dans un clone est cp .env.example .env. Le premier commit emportait les clés.
  • BASE_URL codé en dur sur le domaine du template. En dépendaient metadataBase, les canonicals, le hreflang, les URL OG, le sitemap et la validation de même origine du checkout Stripe. Tout silencieusement faux dans n'importe quel clone, et sans une seule erreur en console.

La détection de la langue était acceptLanguage.includes(locale) : elle ignore l'ordre de préférence du client et tombe juste par coïncidence de sous-chaîne, puisque "sl-SI" contient "sl". Multiplié par les projets qui ont hérité de cette ligne.

Comment le template mère apprend

Les correctifs vont dans l'autre sens : de l'enfant vers la mère. Quand un projet découvre quelque chose, la correction est remontée dans le template et le suivant naît déjà avec. Ça ne fonctionne que si la correction s'accompagne d'un test qui échoue quand on réintroduit le défaut, sinon le prochain refactor « simplifie » la règle et la ramène au bug.

Le dernier gros durcissement de mon template a été une PR de 39 points : idempotence du webhook Stripe avec une table processed_stripe_events, identité toujours issue de la session et jamais passée en argument dans les server actions, userId dans le WHERE de chaque lecture de feedback, validation de même origine sur les URL de retour du checkout. Moins 11 421 lignes, plus 7 268. Presque tout ce qui a été supprimé était des tutoriels décrivant une structure qui n'existait plus.

Et le projet suivant a commencé par chore: inicializa desde la plantilla endurecida. C'est exactement le but.

Un template n'est pas une économie de frappe, c'est un endroit où ranger ce que vous avez déjà appris. Ce qui décide de son utilité, ce n'est pas ce qu'il apporte, c'est la discipline d'y remonter chaque correctif au lieu de le laisser dans l'enfant où il est apparu.