El primer commit d'un projecte meu es diu "Initial commit" i porta ja auth, base de dades, pagaments, i18n i un panell d'admin. El segon, moltes vegades, es diu "remove useless things". Aquesta parella de commits resumeix millor que res com treballo: no començo de zero, començo clonant la meva plantilla i retallant.
Deu projectes així donen un criteri bastant afilat sobre què és nucli i què és soroll. No és una opinió: és a l'historial, i als llocs on es repeteix el mateix esborrat.
El que guanya començar així
Un SaaS de gestió d'incidències per a una empresa de transport va sortir de zero a producció en tres dies. Dia u: clonar i treure el que sobrava. Dia dos: esquema d'incidències, pujada d'imatges, drawer de detall, memòria cau de llistats. Dia tres: landing. Els tres dies van ser íntegrament de domini. Ningú no va tocar un login.
Un SaaS de generació d'imatges va arrencar igual. El dia de l'"Initial commit" ja hi havia esquema d'imatges generades, helper d'S3, branding i tema. L'endemà ja hi havia el pipeline de generació. Auth, Postgres amb Drizzle, Stripe i i18n van venir posats.
El que s'estalvia no és escriure el login. És el mes de decisions petites que arrossega: on viu la sessió, com es protegeix una ruta nova, què passa quan caduca la galeta, com es declaren els idiomes.
El que sempre es queda
- Auth amb una única porta. No una funció
requireSession()que cada pàgina s'ha de recordar de cridar, sinó unlayout.tsxa l'arbre privat. Una pàgina nova queda protegida per on la poses, no per recordar-te de copiar la comprovació. - Postgres amb un ORM tipat i migracions versionades. Aquí hi ha un detall que costa una estona de descobrir: si els snapshots de drizzle es dessincronitzen,
drizzle-kit generatefa el diff contra una base buida i reemetCREATE TABLEde tot l'esquema. S'arregla reconstruint l'snapshot inicial fins quegeneratetorna a dir que no hi ha canvis. - i18n des del minut zero. Ficar-ho després obliga a tocar cada string del projecte. Ficar-ho abans costa una tarda.
- Un admin mínim. Llistat d'usuaris i quatre mètriques. Res més.
El que sempre es retalla
Stripe és el cas clar. En una app de fitxatges, les rutes checkout, portal i webhook es van esborrar senceres el mateix dia del commit inicial: 338 línies fora. En una plantilla de visor intern, idèntic. Dos projectes diferents, un any de diferència, el mateix esborrat.
La conclusió no va ser treure Stripe de la plantilla, sinó posar-lo darrere d'una bandera:
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 projecte sense pagaments ara posa USE_STRIPE=false i continua. Amb un matís que va costar de descobrir: el client de Stripe era null as unknown as Stripe quan la bandera estava apagada, així que el primer ús descurat donava un TypeError opac en comptes de dir què passava. Ara és un Proxy que llança un error explícit.
L'altre retall va ser RLS. Les taules van néixer amb crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) de drizzle-orm/neon. Es van treure totes. El motiu no és que RLS sigui dolent: és que l'app ja feia tots els seus accessos per una connexió d'admin des del servidor, així que les polítiques no s'avaluaven mai i donaven una sensació de seguretat que no existia. Dues capes d'autorització de les quals només una és viva és pitjor que una de sola, perquè ningú no sap quina mana.
I l'admin sobredimensionat. La plantilla de l'equip va acumular mesos de panell d'administració i gestió d'usuaris, i va acabar en un commit que l'elimina sencer. Hereta cost a tots els fills i gairebé cap no el fa servir.
El cost diferit
Això és el que ningú no explica de treballar sobre plantilla: un bug a la mare neix ja clonat a tots els fills.
El més car que he tingut va ser una línia al root layout: alternates.canonical: BASE_URL. Qualsevol pàgina que no sobreescrivís el seu canonical s'autocanonicalitzava a la home i queia de l'índex. Hi va ser mesos, a cada clon. La regla que en vaig treure: falta de canonical és innocu, un canonical equivocat no ho és. Des d'aleshores el layout compartit no posa canonical i cada ruta posa el seu.
Dos més del mateix tipus:
- El
.gitignorenomés ignorava.env.local. El primer pas natural en un clon éscp .env.example .env. El primer commit s'enduia per davant les claus. BASE_URLhardcodejat al domini de la plantilla. D'allà penjavenmetadataBase, els canonicals, l'hreflang, les URL d'OG, el sitemap i la validació de mateix origen del checkout de Stripe. Tot silenciosament malament en qualsevol clon, i sense un sol error a consola.
La detecció d'idioma era acceptLanguage.includes(locale): ignora l'ordre de preferència del client i encerta per coincidència de substring, perquè "sl-SI" conté "sl". Multiplicat pels projectes que van heretar aquella línia.
Com aprèn la plantilla mare
Els arreglaments van al revés: del fill a la mare. Quan un projecte descobreix alguna cosa, la correcció es puja a la plantilla i el següent ja neix amb ella. Això només funciona si la correcció va acompanyada d'un test que falli en reintroduir l'error, perquè si no el següent refactor "simplifica" la regla de tornada al bug.
L'últim enduriment gran de la meva plantilla va ser una PR de 39 punts: idempotència del webhook de Stripe amb una taula processed_stripe_events, identitat sempre des de la sessió i mai per argument a les server actions, userId al WHERE de cada lectura de feedback, validació de mateix origen a les URL de retorn del checkout. Menys 11.421 línies, més 7.268. Gairebé tot el que es va esborrar eren tutorials que descrivien una estructura que ja no existia.
I el projecte següent va començar amb chore: inicializa desde la plantilla endurecida. Aquest és el punt sencer.
Una plantilla no és un estalvi de teclejar, és un lloc on desar el que ja vas aprendre. El que decideix si serveix no és el que porta, és la disciplina de pujar-li cada arreglament en comptes de deixar-lo al fill on va aparèixer.
