Pierwszy commit mojego projektu nazywa się "Initial commit" i przynosi już auth, bazę danych, płatności, i18n i panel admina. Drugi często nazywa się "remove useless things". Ta para commitów podsumowuje mój sposób pracy lepiej niż cokolwiek innego: nie zaczynam od zera, zaczynam od sklonowania swojego szablonu i przycinania.
Dziesięć takich projektów daje dość ostre kryterium tego, co jest rdzeniem, a co szumem. To nie opinia: to siedzi w historii, w tych miejscach, gdzie powtarza się to samo kasowanie.
Co zyskujesz, zaczynając w ten sposób
SaaS do obsługi zgłoszeń dla firmy transportowej przeszedł od zera na produkcję w trzy dni. Dzień pierwszy: sklonować i wyrzucić to, co zbędne. Dzień drugi: schemat zgłoszeń, wgrywanie zdjęć, drawer ze szczegółami, cache list. Dzień trzeci: landing. Wszystkie trzy dni poszły w całości na domenę. Nikt nie tknął logowania.
SaaS do generowania obrazów ruszył tak samo. W dniu "Initial commit" był już schemat wygenerowanych obrazów, helper do S3, branding i motyw. Następnego dnia stał już pipeline generowania. Auth, Postgres z Drizzle, Stripe i i18n przyszły gotowe.
Tym, co oszczędzasz, nie jest napisanie logowania. To miesiąc drobnych decyzji, które ono ciągnie: gdzie mieszka sesja, jak chroni się nową trasę, co się dzieje, gdy wygasa ciasteczko, jak deklaruje się języki.
Co zostaje zawsze
- Auth z jedną jedyną bramą. Nie funkcja
requireSession(), o której wywołaniu każda strona musi pamiętać, tylkolayout.tsxw prywatnej gałęzi drzewa. Nowa strona jest chroniona przez to, gdzie ją położysz, a nie przez to, że pamiętałeś o skopiowaniu sprawdzenia. - Postgres z otypowanym ORM-em i wersjonowanymi migracjami. Jest tu szczegół, którego odkrycie kosztuje chwilę: jeśli snapshoty drizzle się rozjadą,
drizzle-kit generatediffuje względem pustej bazy i wypuszcza ponownieCREATE TABLEdla całego schematu. Naprawia się to przez odbudowanie początkowego snapshotu, ażgenerateznów powie, że nie ma zmian. - i18n od zerowej minuty. Wstawienie go później zmusza do ruszania każdego stringa w projekcie. Wstawienie wcześniej kosztuje popołudnie.
- Minimalny admin. Lista użytkowników i cztery metryki. Nic więcej.
Co zawsze się przycina
Stripe to jasny przypadek. W aplikacji do rejestracji czasu pracy trasy checkout, portal i webhook zostały skasowane w całości tego samego dnia co commit początkowy: 338 linii precz. W szablonie wewnętrznej przeglądarki — identycznie. Dwa różne projekty, rok odstępu, to samo kasowanie.
Wniosek nie był taki, żeby wyrzucić Stripe'a z szablonu, tylko żeby schować go za flagą:
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 });
}
Projekt bez płatności ustawia teraz USE_STRIPE=false i jedzie dalej. Z niuansem, którego odkrycie kosztowało: klient Stripe'a był null as unknown as Stripe, gdy flaga była wyłączona, więc pierwsze nieostrożne użycie dawało nieprzejrzysty TypeError zamiast powiedzieć, o co chodzi. Teraz to Proxy rzucające jawny błąd.
Drugą przycinką było RLS. Tabele rodziły się z crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) z drizzle-orm/neon. Wyrzuciłem wszystkie. Powodem nie jest to, że RLS jest złe: chodzi o to, że aplikacja robiła już wszystkie dostępy przez połączenie administracyjne z serwera, więc polityki nigdy się nie wykonywały i dawały poczucie bezpieczeństwa, którego nie było. Dwie warstwy autoryzacji, z których tylko jedna żyje, są gorsze niż jedna, bo nikt nie wie, która rządzi.
I przerośnięty admin. Zespołowy szablon zebrał miesiące panelu administracyjnego i zarządzania użytkownikami, a skończył commitem kasującym to w całości. Dziedziczy koszt na wszystkie dzieci, a prawie żadne tego nie używa.
Koszt odroczony
To jest to, czego nikt nie mówi o pracy na szablonie: bug w matce rodzi się od razu sklonowany we wszystkich dzieciach.
Najdroższy, jaki miałem, to jedna linia w root layoucie: alternates.canonical: BASE_URL. Każda strona, która nie nadpisała swojego canonicala, kanonikalizowała się do strony głównej i wypadała z indeksu. Siedziało to tam miesiącami, w każdym klonie. Reguła, którą stąd wyniosłem: brak canonicala jest nieszkodliwy, zły canonical nie jest. Od tamtej pory wspólny layout nie ustawia canonicala i każda trasa wstawia swój.
Dwa inne z tej samej rodziny:
.gitignoreignorował tylko.env.local. Naturalnym pierwszym krokiem w klonie jestcp .env.example .env. Pierwszy commit zabierał ze sobą klucze.BASE_URLzahardkodowany na domenę szablonu. Wisiały na nimmetadataBase, canonicale, hreflang, URL-e OG, sitemapa i walidacja tego samego origin w checkoucie Stripe'a. Wszystko po cichu złe w każdym klonie, bez ani jednego błędu w konsoli.
Wykrywanie języka wyglądało tak: acceptLanguage.includes(locale) — ignoruje kolejność preferencji klienta i trafia przez dopasowanie podciągu, bo "sl-SI" zawiera "sl". Pomnóż to przez projekty, które odziedziczyły tę linię.
Jak uczy się szablon matka
Poprawki idą w drugą stronę: od dziecka do matki. Kiedy jakiś projekt coś odkryje, poprawka trafia do szablonu i kolejny rodzi się już z nią. Działa to tylko wtedy, gdy poprawce towarzyszy test wywalający się przy ponownym wprowadzeniu błędu, bo inaczej następny refaktor "uprości" regułę z powrotem do buga.
Ostatnim dużym utwardzeniem mojego szablonu był PR z 39 punktami: idempotencja webhooka Stripe'a przez tabelę processed_stripe_events, tożsamość zawsze z sesji i nigdy jako argument w server actions, userId w WHERE każdego odczytu feedbacku, walidacja tego samego origin w URL-ach powrotnych z checkoutu. Minus 11 421 linii, plus 7268. Prawie wszystko, co skasowane, to były poradniki opisujące strukturę, która już nie istniała.
A kolejny projekt zaczął się od chore: inicializa desde la plantilla endurecida. I o to właśnie chodzi.
Szablon to nie oszczędność stukania w klawiaturę, to miejsce na przechowanie tego, czego się już nauczyłeś. O jego przydatności decyduje nie to, co przynosi, tylko dyscyplina wrzucania do niego każdej poprawki zamiast zostawiania jej w dziecku, w którym się pojawiła.
