02Oordeel5 min

Ik begin nooit bij nul: wat ik altijd meeneem en wat ik altijd wegsnij

Tien projecten op dezelfde boilerplate. De eerste commit van elk lijkt meer op de laatste van het vorige dan op een nieuw project, en de tweede commit verwijdert bijna altijd iets.

Door Salva Castells

Omslagafbeelding: Park taeho, CC BY-SA 4.0.
In dit bericht

De eerste commit van een project van mij heet "Initial commit" en brengt al auth, database, betalingen, i18n en een adminpaneel mee. De tweede heet vaak "remove useless things". Dat commitpaar vat beter dan wat ook samen hoe ik werk: ik begin niet bij nul, ik begin met mijn template klonen en snoeien.

Tien projecten zo geven een behoorlijk scherp oordeel over wat kern is en wat ruis. Het is geen mening: het staat in de historie, en op de plekken waar dezelfde verwijdering zich herhaalt.

Wat zo beginnen oplevert

Een SaaS voor incidentenbeheer bij een transportbedrijf ging in drie dagen van nul naar productie. Dag één: klonen en weghalen wat overbodig was. Dag twee: incidentenschema, afbeeldingen uploaden, detaildrawer, cache voor de lijsten. Dag drie: landingspagina. Alle drie de dagen gingen volledig over het domein. Niemand raakte een login aan.

Een SaaS voor beeldgeneratie startte hetzelfde. Op de dag van de "Initial commit" stond het schema voor gegenereerde afbeeldingen er al, plus de S3-helper, de branding en het thema. De dag erna stond de generatiepipeline er al. Auth, Postgres met Drizzle, Stripe en i18n kwamen kant-en-klaar mee.

Wat je bespaart is niet het schrijven van de login. Het is de maand aan kleine beslissingen die eraan vastzit: waar de sessie woont, hoe je een nieuwe route beschermt, wat er gebeurt als de cookie verloopt, hoe je de talen declareert.

Wat altijd blijft

  • Auth met één enkele deur. Geen functie requireSession() waar elke pagina aan moet denken, maar een layout.tsx in de private boom. Een nieuwe pagina is beschermd door waar je hem neerzet, niet doordat je eraan denkt de check te kopiëren.
  • Postgres met een getypeerde ORM en geversioneerde migraties. Hier zit een detail dat je een tijd kost om te ontdekken: raken de snapshots van drizzle uit sync, dan difft drizzle-kit generate tegen een lege database en zendt het CREATE TABLE van het hele schema opnieuw uit. Je repareert het door de initiële snapshot te herbouwen tot generate weer zegt dat er geen wijzigingen zijn.
  • i18n vanaf minuut nul. Later toevoegen dwingt je elke string in het project aan te raken. Vooraf toevoegen kost een middag.
  • Een minimale admin. Gebruikerslijst en vier metrieken. Meer niet.

Wat er altijd wordt weggesneden

Stripe is het duidelijke geval. In een tijdregistratie-app werden de routes checkout, portal en webhook compleet verwijderd op de dag van de initiële commit: 338 regels eruit. In een template voor een interne viewer precies hetzelfde. Twee verschillende projecten, een jaar ertussen, dezelfde verwijdering.

De conclusie was niet om Stripe uit de template te halen, maar om het achter een vlag te zetten:

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 });
  }

Een project zonder betalingen zet nu USE_STRIPE=false en gaat door. Met één nuance die ik moest ontdekken: de Stripe-client was null as unknown as Stripe als de vlag uit stond, dus het eerste onzorgvuldige gebruik gaf een ondoorzichtige TypeError in plaats van te zeggen wat er aan de hand was. Nu is het een Proxy die een expliciete fout gooit.

De andere snoeibeurt was RLS. De tabellen werden geboren met crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) uit drizzle-orm/neon. Ze zijn allemaal weggehaald. Niet omdat RLS slecht is: het is dat de app al zijn toegang al deed via een adminverbinding vanaf de server, dus de policies werden nooit geëvalueerd en gaven een gevoel van veiligheid dat er niet was. Twee autorisatielagen waarvan er maar één leeft is erger dan één, want dan weet niemand welke het voor het zeggen heeft.

En de overgedimensioneerde admin. De teamtemplate stapelde maandenlang adminpaneel en gebruikersbeheer op, en eindigde in een commit die het compleet verwijdert. Het erft kosten aan alle kinderen en bijna geen enkel kind gebruikt het.

De uitgestelde kosten

Dit is wat niemand vertelt over werken op een template: een bug in de moeder wordt al gekloond in alle kinderen geboren.

De duurste die ik heb gehad was één regel in de root layout: alternates.canonical: BASE_URL. Elke pagina die zijn canonical niet overschreef canonicaliseerde zichzelf naar de home en viel uit de index. Hij stond er maanden, in elke kloon. De regel die ik eruit haalde: een ontbrekende canonical is onschuldig, een verkeerde canonical niet. Sindsdien zet de gedeelde layout geen canonical en zet elke route de zijne.

Nog twee van hetzelfde soort:

  • .gitignore negeerde alleen .env.local. De natuurlijke eerste stap in een kloon is cp .env.example .env. De eerste commit nam de sleutels mee.
  • BASE_URL hardcoded op het domein van de template. Daaraan hingen metadataBase, de canonicals, de hreflang, de OG-URL's, de sitemap en de same-origin-validatie van de Stripe-checkout. Alles stilletjes fout in elke kloon, zonder één enkele fout in de console.

De taaldetectie was acceptLanguage.includes(locale): negeert de voorkeursvolgorde van de client en raakt het goed door substring-overeenkomst, want "sl-SI" bevat "sl". Vermenigvuldigd met de projecten die die regel geërfd hebben.

Hoe de moedertemplate leert

De fixes gaan andersom: van kind naar moeder. Als een project iets ontdekt, gaat de correctie naar de template en wordt het volgende er al mee geboren. Dit werkt alleen als de correctie vergezeld gaat van een test die faalt wanneer de fout opnieuw wordt geïntroduceerd, want anders "vereenvoudigt" de volgende refactor de regel terug naar de bug.

De laatste grote verharding van mijn template was een PR van 39 punten: idempotentie van de Stripe-webhook met een tabel processed_stripe_events, identiteit altijd uit de sessie en nooit als argument in de server actions, userId in de WHERE van elke feedback-lezing, same-origin-validatie op de retour-URL's van de checkout. Min 11.421 regels, plus 7.268. Bijna alles wat verwijderd werd waren tutorials die een structuur beschreven die niet meer bestond.

En het volgende project begon met chore: inicializa desde la plantilla endurecida. Dat is het hele punt.

Een template is geen besparing op typewerk, het is een plek om te bewaren wat je al geleerd hebt. Wat bepaalt of hij nut heeft is niet wat hij meebrengt, maar de discipline om elke fix erin op te nemen in plaats van hem te laten liggen in het kind waar hij opdook.