Nuxt 4 · Supabase · Postgres RLS
Ship multi-tenant SaaS, not scaffolding.
An opinionated Nuxt 4 + Supabase starter where access control lives in the
database, every subsystem is wired but off by default, and one tenant can never
read another's rows — proven by a test, not a promise.
pnpm dlx degit TerrorSquad/gstack my-app # or: Use this template
cd my-app
pnpm install
pnpm setup # pick your integrations; writes .env
pnpm supabase start # local Postgres + Auth (needs Docker)
pnpm db:reset # migrate + seed a demo tenant
pnpm dev # http://localhost:3000
01 — The guarantee
The isolation test
Most starters claim multi-tenancy. This one logs in as a second tenant and
asserts the first tenant's rows are invisible — through the rendered page, the
search box, and the SSR payload. Widen a policy by accident and this goes red.
e2e/tenant-isolation.spec.ts
test('a Globex admin cannot see Acme notes', async ({ page }) => {
await login(page, ADMIN2.email)
// The seeded Acme secret title must be absent from Globex's notes list.
await page.goto('/notes')
await expect(page.getByText(ACME_SECRET_NOTE_TITLE)).toHaveCount(0)
})
Every table carries a tenant_id, and RLS scopes rows through a
current_tenant_id() security-definer helper that reads the caller's own
profile. Page-level role gates are UX only — a bug in a component cannot leak
another tenant's data, because the component never had the rows.
02 — Batteries included
Every subsystem is one env flag
Nothing half-works. Each subsystem is fully wired and switched off until you set
its flag, so a bare clone runs end-to-end with zero third-party accounts —
Supabase runs locally in Docker.
| Subsystem | Flag | Needs an account |
|---|---|---|
| Supabase — Postgres, Auth, RLS, Storage | core, always on | No (local via Docker) |
| Feedback widget → your own DB | NUXT_PUBLIC_FEEDBACK_ENABLED | No |
| Onboarding tour (driver.js) | NUXT_PUBLIC_TOUR_ENABLED | No |
| Email notifications | NUXT_PUBLIC_NOTIFICATIONS_ENABLED | Resend |
| Billing — checkout, portal, webhooks | NUXT_PUBLIC_BILLING_ENABLED | Polar |
| Product analytics + feature flags | NUXT_PUBLIC_POSTHOG_ENABLED | PostHog |
| GitHub / Google OAuth login | set the keys | GitHub / Google |
| Error tracking | set the DSN | Sentry |
| Log forwarding | set the token | BetterStack |
pnpm setup writes the flags, pnpm doctor verifies them, and both read the
same manifest — so a subsystem can't be half-configured without one of them
saying so.
03 — Proof, not adjectives
What CI won't let you break
The interesting part of a starter isn't what it ships on day one, it's what it
refuses to let you regress on day ninety.
| Gate | What it catches |
|---|---|
pnpm lint | oxlint + oxfmt, auto-fixing on pre-commit |
pnpm lint:i18n | a key added to en but not sr, or a key nothing uses |
pnpm test | logic + the email-shell drift test: auth templates that no longer match their generator |
pnpm typecheck | a query that under-selects, against types generated from the live schema |
pnpm test:e2e | tenant isolation, auth flows, notes CRUD |
| axe, twice daily | contrast and landmark failures, on every page, in both light and dark |
Releases are cut by release-please from conventional commits; the user-facing changelog is curated separately, so shipping a refactor doesn't spam your users.
04 — Architecture
Features are layers, not folders
Marketing, notes, admin, account, billing, email, feedback, tour and analytics are ten independent Nuxt layers over a shared design-system layer. Adding a feature is one scaffold command plus one line in extends — and deleting one is deleting a directory.
- Type-safe end to endPostgres schema → generated types → composables → UI. No ORM. Change a column and the build tells you every call site that cared.
- Auth that survives SSREmail/password plus GitHub and Google OAuth, password reset, confirmation, and a role-aware global middleware instead of the Supabase module's redirect.
- One email shellTransactional mail and the Supabase auth templates render from the same generated shell, so the branding can't drift. A unit test fails if it does.
- Bilingual from the startEnglish and Serbian, with key parity enforced in CI. Dynamic keys are written so the usage checker can still resolve them.
- Portable deployNitro output, so Vercel is the default and not a lock-in. Supabase runs locally in Docker for development and CI alike.
- Decisions on the recordThe reversible-but-significant choices — Polar over Stripe, no ORM yet, layers over a monorepo — are written down as ADRs with their trade-offs.
Free and MIT licensed
Clone it, rename it with one command, and start building your product instead of
its scaffolding.