Blank-slate Next.js SaaS scaffolding with Mailtrap as the email layer, Supabase auth, and Stripe billing.
For learners, indie hackers, and juniors — not production-grade multi-tenant SaaS.
- Node.js 20+
- pnpm 10+
- Docker Desktop (for local Supabase)
- Stripe CLI (for local billing webhooks)
- Mailtrap account (API token + Email Testing inbox + hosted templates)
git clone https://github.com/mailtrap/nextjs-saas-starter.git
cd nextjs-saas-starter
pnpm install
pnpm db:setup # Docker: Supabase Auth + Postgres + schema → writes .env.localCopy remaining values from .env.example into .env.local:
- Mailtrap — API token, sandbox inbox ID, from address, 8 template UUIDs
- Stripe — secret key, webhook secret (from Stripe CLI below), Pro + Team price IDs
- Admin — your email in
ADMIN_EMAILS(for/admin/emails)
Optional Stripe test products/prices:
stripe fixtures stripe-fixtures.json --api-key "$STRIPE_SECRET_KEY"Start the app (port comes from NEXT_PUBLIC_APP_URL):
pnpm devOpen the URL in .env.local (default http://127.0.0.1:3000).
NEXT_PUBLIC_APP_URL is the single source of truth for:
- Next.js dev port (
pnpm dev) - Supabase
site_urland auth redirects - Send Email Hook URI (Docker → host app)
If port 3000 is taken, set e.g. NEXT_PUBLIC_APP_URL=http://127.0.0.1:3001, then:
pnpm sync:env
pnpm db:stop && pnpm db:start
pnpm dev- Starts only Supabase Auth + Postgres (not Storage, Studio, Realtime, etc.)
- Applies
supabase/migrations/(source of truth for schema) - Writes Supabase credentials into
.env.local - Syncs auth hook URL and redirect URLs from
NEXT_PUBLIC_APP_URL
Stop Docker: pnpm db:stop · Reset database: pnpm db:reset · Restart: pnpm db:start
Create a project at supabase.com, run SQL from supabase/migrations/, copy API keys into .env.local, and configure:
- Auth → URL → your
NEXT_PUBLIC_APP_URL - Auth → Hooks → Send Email →
https://YOUR_APP/api/auth/send-email
In a second terminal (use the port from NEXT_PUBLIC_APP_URL):
stripe listen --forward-to localhost:3001/api/webhooks/stripe --api-key "$STRIPE_SECRET_KEY"Copy the webhook signing secret into STRIPE_WEBHOOK_SECRET in .env.local.
Set MAILTRAP_WEBHOOK_SECRET in .env.local, then simulate an event:
pnpm webhook:test:mailtrapRefresh /admin/emails to see the test event. In production, point Mailtrap webhooks at https://YOUR_APP/api/webhooks/mailtrap.
| Group | What you need |
|---|---|
| App | NEXT_PUBLIC_APP_URL — dev port, redirects, Supabase hook (pnpm sync:env after changes) |
| Supabase | 4 vars — auto-filled by pnpm db:setup locally |
| Stripe | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_PRO, STRIPE_PRICE_TEAM |
| Mailtrap | API token, sandbox flag + inbox ID, from address, 8 template UUIDs, webhook secret |
| Auth hook | SEND_EMAIL_HOOK_SECRET — auto-filled locally |
| Admin | ADMIN_EMAILS — comma-separated emails for /admin/emails |
Full list: .env.example
MAILTRAP_SANDBOX=true
MAILTRAP_ACCOUNT_ID=12345 # optional, silences SDK warning
MAILTRAP_TEST_INBOX_ID=123456 # Email Testing → inbox settings
MAILTRAP_WEBHOOK_SECRET=your-secret # local: any string; match in Mailtrap dashboard for prodEmails appear in your Mailtrap Email Testing inbox, not the recipient's real mailbox. Set MAILTRAP_SANDBOX=false before production deploy.
src/app/ → routes (marketing, auth, dashboard, API webhooks)
src/actions/ → Server Actions (auth, billing, org, settings)
src/lib/mailtrap → sendEmail(), webhook signature verify
src/config/emails → typed template UUID map
src/db/ → Drizzle schema (profiles, orgs, subscriptions, email events)
Email flows: Supabase Send Email Hook → Mailtrap (magic link, password reset, email change). App events → sendEmail() (welcome, invites, billing).
Billing: Stripe Checkout for upgrades; downgrades cancel or update the existing subscription via changePlan().
- Create
src/features/your-feature/— seesrc/features/README.md - Add a route under
src/app/dashboard/your-feature/page.tsx(or another app route) - Call
sendEmail()from@/lib/mailtrapfor new transactional emails
pnpm db:setup # first-time local setup (Docker + migrations + .env.local)
pnpm db:start # start Supabase + reset + sync env
pnpm db:stop # stop Docker Supabase
pnpm db:reset # re-apply supabase/migrations
pnpm sync:env # sync Supabase auth URLs from NEXT_PUBLIC_APP_URL
pnpm dev # Next.js dev server (port from NEXT_PUBLIC_APP_URL)
pnpm build && pnpm start # production build
pnpm lint
pnpm typecheck
pnpm test # unit tests (vitest)
pnpm test:e2e # Playwright
pnpm webhook:test:mailtrap # local Mailtrap webhook test (no ngrok)
pnpm lighthouse # Lighthouse CISchema changes: edit supabase/migrations/, then pnpm db:reset. The drizzle/ folder mirrors schema for Drizzle ORM queries; db:generate / db:push are optional and not used by default setup.
- Run
supabase/migrations/SQL on production Postgres (or link hosted Supabase) - Set all env vars from
.env.example - Stripe → Webhooks →
https://YOUR_APP/api/webhooks/stripe - Mailtrap → Webhooks →
https://YOUR_APP/api/webhooks/mailtrap - Supabase → Auth → Hooks → Send Email →
https://YOUR_APP/api/auth/send-email - Paste Mailtrap template UUIDs and verify sending domain
- Set
ADMIN_EMAILSfor admin access
MIT