The Hidden Cost of Rewriting: Time vs. Templates
The price nobody budgets for
Three rewrites from zero will eat two months of a solo build. Most of that cost shows up as context switches, not as line items. The "from-scratch" crowd quotes a price in components shipped. The real price is in the evenings you spent learning what you didn't yet know you needed.
I've done both, twice. Here is the time accounting, in evenings, not dollars.
What "auth from scratch" actually means
The signup form is two fields and a button. The system behind it is twelve things, and you will discover three of them in production:
- Password hashing (argon2 or bcrypt — not SHA, not MD5)
- Session table and a middleware that reads the cookie on every request
- Email verification flow with a signed token
- Password reset flow, also signed, with an expiry
- At least one OAuth provider (Google + GitHub is the floor)
- Rate limiting on
/loginand/signup - CSRF token on every form post
- Secure cookie flags: HttpOnly, SameSite=Lax, Secure in prod
- User-facing pages:
/login,/signup,/forgot, the verify-email landing - Error states: wrong password, expired token, already-verified email, locked after N failures
- A logout endpoint that invalidates the session row, not just clears the cookie
- An audit log, even a tiny one, because the day you don't have it is the day you need it
The signup route is the smallest piece. The handler looks roughly like this:
// app/api/auth/signup/route.ts
export async function POST(req: Request) {
const { email, password } = await req.json()
if (await rateLimit.exceeded(`signup:${email}`)) {
return new Response('Too many requests', { status: 429 })
}
if (await db.findUserByEmail(email)) {
// generic message — don't leak which side failed
return Response.json({ ok: true })
}
const passwordHash = await argon2.hash(password)
const user = await db.insert(users).values({ email, passwordHash }).returning()
await sendVerificationEmail(user.email, signToken({ sub: user.id, email }))
return Response.json({ ok: true })
}Now multiply that handler shape by: login, logout, OAuth callback, reset-request, reset-confirm, verify, and /me. A tight implementation, before any polish, is 30 to 50 solo-builder evenings. The first time. With one self-test per route.
11 production screens. Login, database, payments — all wired.
The SaaS Dashboard Kit ships everything already connected. Nothing to set up. Live demo at saas.otf-kit.dev.
What "DB layer from scratch" actually means
You're not really choosing a database. You're choosing five things at once:
- Where the schema lives (a migrations folder you trust)
- How the schema reaches the app — typed client, ORM, or raw queries you wrap yourself
- How the app reaches the schema in prod — connection pooling, serverless HTTP driver, the boring parts
- How you model soft-delete (or admit you don't, and stop pretending)
- How you index for the queries you'll actually run
The schema for just auth looks like this:
create table users (
id uuid primary key default gen_random_uuid(),
email text unique not null,
password_hash text not null,
email_verified_at timestamptz,
created_at timestamptz not null default now(),
deleted_at timestamptz
);
create index users_email_lower_idx on users (lower(email));
create table sessions (
id text primary key,
user_id uuid not null references users(id) on delete cascade,
expires_at timestamptz not null,
created_at timestamptz not null default now()
);
create index sessions_user_idx on sessions (user_id);Two tables. One set of indexes. Then add accounts and verification_tokens if you took the OAuth path. Then add subscriptions, workspaces, members, and the Stripe webhook. Then the org-invite flow.
A bare DB layer for a real SaaS is 20 to 40 evenings if you're writing migrations and a typed query helper yourself.
What "design system from scratch" actually means
A Button is half a day. A Button with hover, focus-visible, disabled, loading, four variants, three sizes, dark mode, and a press state that feels right on mobile is two days. A Dialog with focus-trap, escape-to-close, click-outside, scroll-lock, portal, and ARIA roles is three days. Then Toast, Dropdown, Tabs, Tooltip, Checkbox, Switch, Select, Combobox, Form, Sheet, AlertDialog…
You're not building one Button. You're building roughly ten primitives to a quality bar they can be used 200 times without surprises.
Plus tokens. Plus a light/dark switch that doesn't break the contrast ratio. Plus an icon set (or paying $50–$300 for one). Plus responsive breakpoints that actually work on a phone in landscape.
A real, themable, a11y-passing design system is 60 to 100 evenings solo. This is the number experienced frontend engineers will read and call conservative. It is.

The compounding: these aren't three sequential jobs
The naive math is 40 + 30 + 80 = 150 hours. The real math is bigger, because the layers block each other:
- Auth needs a
usersrow. You can't start auth until the DB exists. - The signup form needs a
<Button>and an<Input>. You can't render auth until the design system exists. - The design system needs real consumers to harden — and the signup form is that consumer.
- Each layer ships a tiny prototype, then you go back and change the previous layer to match.
For a solo builder, there's no parallelization. Sequential and interleaved is the right mental model, not sequential. Budget 25–40% more than the sum of the parts. Call it 180 to 250 evenings from a clean repo to a deployed MVP with auth, a real DB, and a UI you'd actually ship.
The second cost, the one nobody quotes
The auth library you pick today will be the wrong one in six months. The ORM you commit to will get a new query builder you want. The component primitive you choose will release a v2 with breaking changes. That is the cost of maintaining a from-scratch stack: every model or tool release is a half-day of "do I migrate yet?"
A wired kit is version-locked. It pins the implementation of every layer it ships. When the auth lib you wrote gets old, you own the migration. When a kit's underlying auth lib gets old, you opt in on a release that's already been tested by everyone using the kit.
The hours saved aren't on day one. They're on day 180, day 365, day 500. That's the half of the cost nobody budgets for, because the budget was written on day 1.

What starting from a wired kit actually buys you
On day one, a wired full-stack kit gives you:
- Auth routes, middleware, DB schema, and the user-facing pages — all wired to each other, all tested against each other
- Migrations and indexes checked in
- A component library where the same
<Button>renders on web, iOS, and Android from one codebase - A token set so light and dark flip across platforms with a single edit
- A deploy script that wires a custom domain, DNS, and TLS, plus a mobile build, in one command
- Editor config files for the major AI coding agents — CLAUDE.md, .cursorrules, and 20+ tested prompts — so your agent extends the kit instead of regenerating it from scratch every session
The price is what a tank of gas costs. Same effect for the next month: roughly 3 evenings from git clone to a deployed URL with auth live, instead of 150+. The next 6 months of maintenance drag shrink to whatever migrations you choose to opt into.
The honest tradeoff
If the goal is to learn how auth works end to end: build it from scratch, then throw it away. That's a teaching exercise with the right cost attached.
If the goal is to ship: start wired. The teaching happens between v1 and v2, when you have real users breaking real flows, which is the only kind of learning that survives contact with a launch.
The two paths aren't the same. The build-from-scratch posts always quote the polished final number, never the 1am "why is the cookie not being set" number. The kit posts sometimes under-quote what you don't get to learn firsthand. Both are real. Pick the one that matches what you actually need from the next twelve weeks of evenings.
Ship the product, not the setup.
- 11 production screens — auth, billing, team, analytics, settings
- Real database, payments, and login — all wired on day 1
- AI configs pre-tuned so your agent extends instead of regenerates