Applied AI, UX Lead at Google
GenUX/UI · LLM Evals · Autoraters
An opinionated, battle-tested stack for any new SaaS web app: Next.js App Router + Supabase + Tailwind v4 + Stripe + Resend + Vercel, with optional AI on top. Includes a 10-step quickstart from npx create-next-app to deployed-and-billing, sample RLS migration, Stripe webhook skeleton, env validation pattern, and a CLAUDE.md template so a coding agent is productive from day one.
$ npx skills add darrenhead/skills --skill saas-starterA battle-tested, opinionated stack for shipping any new SaaS web app in days. Every layer below is here because removing it would force you to either pick a worse alternative or pay an engineer to maintain a custom one.
Core thesis: the stack you pick is the team you don't have. Choose layers that compose, set up the boring foundations correctly once, then spend your real attention on the product.
git init to deployed-and-billing in days, not weeks.strict: true, noUncheckedIndexedAccess: true, noImplicitOverride: true). Don't relax later — backfilling types is weeks.select from any tenant-scoped table should be physically incapable of returning another tenant's row.ai + provider packages). Provider-agnostic, so you can switch between Gemini / Claude / GPT without rewriting calls.CLAUDE.md that names every module, primitive, env var, migration. Treat the agent as a peer engineer joining tomorrow.These translate across every product. Get them right at t=0 and you won't repay them later.
A tsconfig.json with strict: true + noUncheckedIndexedAccess: true + noImplicitOverride: true is the cheapest insurance you'll buy. Don't add // @ts-ignore to make a stack trace go away — fix the type.
Tenancy at the database boundary, not the application. A forgotten filter in app code is a data breach; a missing RLS policy is caught the moment another user logs in.
-- Pattern for any user-scoped table
create table notes (
id uuid primary key default gen_random_uuid(),
user_id uuid not null references auth.users(id) on delete cascade,
content text not null,
created_at timestamptz not null default now()
);
alter table notes enable row level security;
create policy "users read their own notes"
on notes for select
using (auth.uid() = user_id);
create policy "users write their own notes"
on notes for insert
with check (auth.uid() = user_id);
Don't query Stripe at request time. Process webhooks, write the resulting state to Postgres, read from Postgres for every check. Idempotency keyed on stripe_event.id.
Set up /[locale]/... routes from t=0 even if en is your only locale. Adding locales later is a string-tables exercise; retrofitting locale-aware URLs is a re-architect.
Centralise auth + role checks behind helpers (requireUser(), requirePlan('pro')). Pages and route handlers call helpers; they don't inline conditionals. Future you will change auth rules in one place.
Every schema change is a migration file in the repo. No "I ran this in the dashboard." If it's not in supabase/migrations/, it doesn't exist.
A lib/env.ts that validates process.env with Zod at startup. A missing or misnamed env var should fail the build, not surface as a runtime 500 in week three.
Write a dense CLAUDE.md that enumerates every module, env var, migration, and critical rule. Treat the agent like a new hire who has to ship by Friday. The skill returns as ~30% velocity over months.
.eq("tenant_id", ...) is a data breach. RLS catches it at the boundary.STRIPE_*, SUPABASE_*. Validate them once at boot.10 numbered steps from empty directory to deployed-and-billing.
npx create-next-app@latest my-saas --typescript --tailwind --app --turbopack
cd my-saas
Pin strict TS in tsconfig.json:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"moduleResolution": "bundler"
}
}
npm install @supabase/ssr @supabase/supabase-js
npm install stripe @stripe/stripe-js
npm install resend react-email @react-email/components
npm install zod
npm install -D @types/node
Sign up at supabase.com → create project → enable Auth providers you want (email, Google, GitHub).
Enable extensions you'll likely need:
create extension if not exists "pgcrypto";
create extension if not exists "vector"; -- only if you'll do RAG
lib/supabase/server.ts, lib/supabase/client.ts, lib/supabase/middleware.ts — three clients (server component, client component, middleware-for-session-refresh). Pattern from supabase.com/docs/guides/auth/server-side/nextjs.
supabase/migrations/0001_init.sql — extend auth.users with a profiles table for your app-specific fields, plus the RLS-pattern example above.
Sign up at stripe.com → create products + prices (use Test mode). Add webhook endpoint /api/stripe/webhook in Stripe Dashboard. Copy webhook signing secret to .env.local.
Webhook handler skeleton:
// app/api/stripe/webhook/route.ts
import { headers } from "next/headers"
import Stripe from "stripe"
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(req: Request) {
const sig = (await headers()).get("stripe-signature")!
const body = await req.text()
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET!,
)
} catch {
return new Response("Bad signature", { status: 400 })
}
// Idempotency — skip if we've seen this event id.
// Persist event → subscription state in Postgres.
return new Response(null, { status: 200 })
}
// lib/email/send.ts
import { Resend } from "resend"
const resend = new Resend(process.env.RESEND_API_KEY!)
export async function sendEmail(/* ... */) { /* ... */ }
emails/Welcome.tsx is your first React Email template.
lib/env.ts — typed + validated envimport { z } from "zod"
const schema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
RESEND_API_KEY: z.string().min(1),
})
export const env = schema.parse(process.env)
Importing env at boot crashes the build if anything's missing — the failure mode you want.
# CLAUDE.md
## Project
<one-line description of the SaaS>
## Stack
- Next.js (App Router) + TypeScript strict
- Supabase (Postgres + Auth + Storage)
- Stripe subscriptions, webhook at /api/stripe/webhook
- Resend transactional email
- Vercel deploys
## Layout
- app/ — App Router routes
- lib/supabase/ — server, client, middleware
- lib/email/ — Resend wrapper + templates
- supabase/migrations/ — schema as code
- emails/ — React Email templates
## Env vars
- NEXT_PUBLIC_SUPABASE_URL
- NEXT_PUBLIC_SUPABASE_ANON_KEY
- SUPABASE_SERVICE_ROLE_KEY
- STRIPE_SECRET_KEY
- STRIPE_WEBHOOK_SECRET
- RESEND_API_KEY
## Critical rules
- Every tenant-scoped table gets RLS in the same migration.
- Webhook → DB → read. Never query Stripe at request time.
- No schema changes outside supabase/migrations/.
- Strict TS — no @ts-ignore.
npx vercel
Add the same env vars in Vercel Dashboard. Push to GitHub → branch deploys live automatically. Ship something user-facing in week one. Don't pre-build admin tooling — add it when you have something to administer.
Add when the product calls for them, not before.
gemini-embedding-001 (768 dims) or OpenAI text-embedding-3-small. HNSW index, RLS-scoped retrieval.The stack is opinionated, not religious. Diverge where your product shape demands it.
payment_intents.A reference implementation lives at github.com/darrenhead/saas-starter (push pending — when it lands you can git clone and skip steps 1-2 of the quickstart). Until then, the checklist above is enough to be productive from npx create-next-app forward.