The first commit of one of my projects is called "Initial commit" and it already ships auth, database, payments, i18n and an admin panel. The second one is often called "remove useless things". That pair of commits sums up how I work better than anything else: I don't start from scratch, I start by cloning my template and cutting.
Ten projects like that give you a fairly sharp sense of what is core and what is noise. It isn't an opinion: it's in the history, and in the places where the same deletion keeps happening.
What you gain by starting this way
An incident management SaaS for a transport company went from zero to production in three days. Day one: clone and remove what wasn't needed. Day two: incident schema, image upload, detail drawer, list caching. Day three: landing page. All three days were pure domain work. Nobody touched a login.
An image generation SaaS started the same way. On the day of the "Initial commit" there was already a schema for generated images, an S3 helper, branding and theme. The next day the generation pipeline was in. Auth, Postgres with Drizzle, Stripe and i18n came for free.
What you save isn't writing the login. It's the month of small decisions that come with it: where the session lives, how you protect a new route, what happens when the cookie expires, how languages get declared.
What always stays
- Auth with a single door. Not a
requireSession()function that every page has to remember to call, but alayout.tsxin the private tree. A new page is protected by where you put it, not by remembering to copy the check. - Postgres with a typed ORM and versioned migrations. There's one detail here that takes a while to find: if the drizzle snapshots drift out of sync,
drizzle-kit generatediffs against an empty database and re-emitsCREATE TABLEfor the whole schema. You fix it by rebuilding the initial snapshot untilgeneratesays there are no changes again. - i18n from minute zero. Adding it later means touching every string in the project. Adding it up front costs an afternoon.
- A minimal admin. A user list and four metrics. Nothing else.
What always gets cut
Stripe is the clear case. In a time-tracking app, the checkout, portal and webhook routes were deleted entirely on the day of the initial commit: 338 lines gone. In an internal viewer template, the same thing. Two different projects, a year apart, the same deletion.
The conclusion wasn't to take Stripe out of the template, but to put it behind a 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 });
}
A project with no payments now sets USE_STRIPE=false and moves on. With one nuance that took a while to find: the Stripe client was null as unknown as Stripe when the flag was off, so the first careless use threw an opaque TypeError instead of saying what was going on. Now it's a Proxy that throws an explicit error.
The other cut was RLS. The tables were born with crudPolicy({ role: authenticatedRole, read: authUid(table.userId) }) from drizzle-orm/neon. All of them were removed. The reason isn't that RLS is bad: it's that the app already did every access through an admin connection from the server, so the policies were never evaluated and gave a sense of security that didn't exist. Two authorization layers where only one is alive is worse than one, because nobody knows which one rules.
And the oversized admin. The team template accumulated months of admin panel and user management, and ended up in a commit that deletes the whole thing. It passes cost down to every child and almost none of them use it.
The deferred cost
This is what nobody tells you about working on a template: a bug in the parent is born already cloned into every child.
The most expensive one I've had was a single line in the root layout: alternates.canonical: BASE_URL. Any page that didn't override its canonical self-canonicalized to the home page and dropped out of the index. It sat there for months, in every clone. The rule I took from it: a missing canonical is harmless, a wrong canonical is not. Since then the shared layout sets no canonical and each route sets its own.
Two more of the same kind:
.gitignoreonly ignored.env.local. The natural first step in a clone iscp .env.example .env. The first commit took the keys with it.BASE_URLhardcoded to the template's domain. Hanging off it weremetadataBase, the canonicals, the hreflang, the OG URLs, the sitemap and the same-origin validation of the Stripe checkout. All silently wrong in any clone, without a single error in the console.
Language detection was acceptLanguage.includes(locale): it ignores the client's preference order and matches by substring, because "sl-SI" contains "sl". Multiplied by every project that inherited that line.
How the parent template learns
Fixes travel backwards: from child to parent. When a project discovers something, the fix goes up into the template and the next one is born with it. This only works if the fix comes with a test that fails when the bug is reintroduced, because otherwise the next refactor "simplifies" the rule straight back into the bug.
The last big hardening pass on my template was a 39-point PR: Stripe webhook idempotency with a processed_stripe_events table, identity always from the session and never passed as an argument to server actions, userId in the WHERE of every feedback read, same-origin validation on checkout return URLs. Minus 11,421 lines, plus 7,268. Almost everything deleted was tutorials describing a structure that no longer existed.
And the next project started with chore: inicializa desde la plantilla endurecida. That's the whole point.
A template isn't a way to save typing, it's a place to store what you already learned. What decides whether it's useful isn't what it ships with, it's the discipline of pushing every fix up instead of leaving it in the child where it showed up.
