Zero → WebApp with Claude

#ai#llm#web#automation#react

This is part two. Part one: A Second Brain for Building with Claude was about the brain: a Notion second brain that holds a project's state, and the /next skill that turns its roadmap into commits. This post is about the second reusable asset, the standard stack, and how the parts connect into a shipped web app.

The stack is the part most "vibe coding" posts skip. The reason I can go from idea to deployed SaaS quickly isn't just a clever prompt, it's that I deploy the same way every time. Next.js on Vercel. Supabase for database, auth, and storage. Stripe for money. Because the shape is fixed, each new feature is muscle memory, and Claude has seen the pattern a thousand times in its training data, so it writes it well.

Here's the ladder this post climbs:

create-next-app + git push       →  a live URL                 (minutes)
+ Supabase Postgres              →  data that persists
+ Supabase Auth                  →  real users, real sessions
+ Stripe Checkout + webhook      →  money, entitlements
──────────────────────────────────────────────────────────────
                                    a complete SaaS

The worked example is antetu.com, a real app I shipped exactly this way. Everything below is what /next built, one rung at a time.


Rung 0: the simplest possible web app

The goal here is not features. It's a live URL with a deploy pipeline, before any complexity exists.

pnpm create next-app@latest antetu      # App Router, TypeScript, Tailwind
cd antetu && git init && gh repo create
git push -u origin main

Then import the repo on Vercel once. That's the entire setup. After that, every push to main deploys to production, and every push to a branch gets its own preview URL. No CI to configure, no Dockerfile, no server to provision. Vercel detects Next.js and does the right thing.

This matters more than it looks. Before I've written a line of real logic, I have:

  • a production URL I can send to someone,
  • a preview deploy on every branch (so /next's per-task branches are each independently viewable),
  • and a deploy I never think about again.

Antetu at this stage was a static marketing page and a questionnaire that stored answers in localStorage under antetu_family_members. No database, no accounts, no backend. People could use the core product anonymously. That anonymous tier never went away, it became the bottom of a three-tier model (anonymous → free signed-in → paid) as the app grew.


Rung 1: persistence (Supabase Postgres)

localStorage is a great Rung 0 cheat, but data trapped in one browser isn't a product. Time for a database.

I add Supabase through the Vercel Marketplace, which provisions a managed Postgres instance and syncs the connection env vars into the project automatically. The schema lives in the repo as plain SQL migrations, checked in, reviewable, greppable:

supabase/migrations/
  0001_init.sql          family_members, answers
  0002_rls.sql           row-level security policies
  ...
  0010_trusted_contact_members.sql

Antetu's core tables: family_members, and answers as an append-only audit log (the latest row per (family_member_id, question_id) is the current value, you never lose history). Migrations are how the database stays a version-controlled artifact instead of a pile of clicks in a dashboard.

The storage layer dispatches on auth state, which is what lets the anonymous tier coexist with the cloud one:

anonymous  → localStorage           (the Rung 0 path, still alive)
signed in  → Supabase via API route (the cloud path)

When an anonymous user later signs in, a migration prompt offers to copy their local data into Postgres. The Rung 0 work didn't get thrown away, it got absorbed.


Rung 2: real users (Supabase Auth)

Persistence without identity just means one shared blob. Accounts are the next rung, and because I'm already on Supabase, auth lives next to the data, no second vendor, no syncing user IDs between two systems.

Antetu uses Supabase Auth with two sign-in methods:

  • Google OAuth for one-click sign-in,
  • email magic link for people who'd rather not use Google (Resend delivers the email).

Sessions are server-rendered via @supabase/ssr and stored in httpOnly cookies, so the user is known on the very first paint (no flash of logged-out UI) and JavaScript can't read the session token (kills most XSS session theft). A signed-in user is just a row in Supabase's auth.users table, identified by a UUID, and everything user-owned is scoped by that UUID.

Here's the one opinionated call worth stealing. Antetu is server-authoritative: all database reads and writes for signed-in users go through Next.js API routes, not the browser Supabase client.

Browser  ──fetch──▶  /api/...  ──requireUser()──▶  service-role client  ──▶  Postgres
                         │
                         └─ scopes every query by user.id, in code

Each route verifies the caller (requireUser() / requirePaidUser()), then queries with the service-role client, scoping by user.id explicitly. Row-Level Security still pins every row to auth.uid() as a backstop, so the anon key shipped to the browser literally cannot read another user's rows. But the primary enforcement is plain route code you can grep and test.

The browser Supabase client is allowed exactly one job: auth operations (sign in, sign out, onAuthStateChange). It never touches .from(...). That single rule is the whole security posture in one sentence.


Rung 3: money (Stripe subscriptions)

The top rung turns a free app into a business. Antetu sells an annual premium plan (trusted contacts, file uploads), and the billing flow is a loop, not a button:

/api/checkout   →  Stripe Checkout (subscription mode)  →  user pays
                                                              │
Stripe  ──webhook──▶  /api/webhooks/stripe  ──upsert──▶  subscriptions table
                                                              │
                                          useAuth().isPaid  ◀─┘

Three server routes do all the work:

  • POST /api/checkout: creates or reuses a Stripe customer for the user, opens a Checkout Session in subscription mode against a price ID, returns a { url } to redirect to.
  • POST /api/webhooks/stripe: verifies the stripe-signature header against the webhook secret, then upserts subscription state. This is the source of truth, not the client.
  • POST /api/portal: hands back a Stripe Billing Portal URL so users manage their own plan.

Entitlement is derived, never stored as a flag a client could flip:

isPaid =
  status in ('active', 'trialing') &&
  (current_period_end == null || current_period_end > now());

The root layout server-fetches the subscription row alongside the user, so a returning subscriber never watches their paid features flash locked before the app catches up. And the webhook is hardened the way real money demands: signature-verified, deduplicated via a stripe_webhook_events table, and resilient to out-of-order delivery (it reconciles from the live customer.subscription.* events, not just the checkout payload).

Local development uses the Stripe CLI to forward webhooks to localhost:

stripe listen --forward-to localhost:3000/api/webhooks/stripe
# test card: 4242 4242 4242 4242

The thing that bites everyone: secrets and env vars

Across all four rungs, one discipline keeps the whole thing safe: secrets never touch the repo. .env.local is gitignored; production secrets live in Vercel's environment variables, scoped per environment.

VarScopeNotes
NEXT_PUBLIC_SUPABASE_URL / ..._ANON_KEYpublicSafe to ship. RLS is what makes that OK.
SUPABASE_SERVICE_ROLE_KEYsecret, server-onlyBypasses RLS. Imported by exactly one admin module.
STRIPE_SECRET_KEYsecretsk_test_... for preview/local, sk_live_... for prod.
STRIPE_WEBHOOK_SECRETsecretDifferent value per environment (local CLI vs prod endpoint).

The service-role key bypassing RLS is the scariest object in the system, so it's grep-auditable: it appears in one file, imported only by /api/** routes. If a secret ever lands in git by accident, rotate it immediately. Git history is forever.


The standard stack, in one table

This is the recipe I now reach for by default.

ConcernChoiceWhy
FrameworkNext.js App Router (React 19, TS)Vercel-native, huge training corpus, server + client in one repo.
HostingVercelZero-config deploys, a preview URL per branch, env management.
DatabaseSupabase PostgresManaged, migrations in-repo, RLS as defense-in-depth.
AuthSupabase AuthLives next to the DB; Google + magic link out of the box.
File storageSupabase StoragePrivate buckets, signed URLs, same auth context as the DB.
BillingStripe Checkout + webhooksSubscriptions, Customer Portal, server-verified entitlement.
EmailResendMagic links and transactional mail.
Data accessServer-authoritative API routesAuth logic in greppable, testable code; RLS underneath.

Why this works

The real payoff shows up on the second project. The stack is identical, so Claude is never improvising architecture and I'm never reviewing an unfamiliar shape. The brain from part one means an app I haven't opened in a month is one /next away from productive. The two assets compound: the more I reuse them, the cheaper each rung gets.

And the documentation writes itself. By the time the roadmap is empty there's a deployed app and a Notion page that already describes it, because building the thing and maintaining the notes were the same act. The Done tasks are a changelog. The Architecture page is current by construction. I never sat down to "write docs," they fell out of the loop.


Try it

You'll want the brain from part one set up first - the template duplicated and /next wired to your repo. Then climb:

  1. pnpm create next-app, push, import once on Vercel. You have a live URL.
  2. Add Supabase from the Vercel Marketplace; commit your first migration.
  3. Wire Supabase Auth, then Stripe Checkout + webhook, one /next per rung.

The model was never the bottleneck. The memory was, and the stack was. Give Claude a brain and a recipe, and the blank repo stops being scary. It's just the bottom of a ladder you've climbed before.

Thanks for reading. More soon.