Ship multi-tenant SaaS, not scaffolding.
GStack is 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
Batteries included, every one flag-gated
Nothing half-works. Each subsystem is fully wired and switched off until you set its env flag, so a bare clone runs with no third-party account.
- RLS-first multi-tenancyEvery table carries a tenant_id and Postgres Row Level Security scopes rows to the caller's tenant. Page-level role gates are UX only — a bug in a component cannot leak another tenant's data.
- Auth that survives SSREmail/password plus GitHub and Google OAuth, password reset, email confirmation, and a global role-aware middleware instead of the Supabase module's redirect.
- Features as Nuxt LayersMarketing, notes, admin, account, billing, email, feedback, tour and analytics are independent layers. Adding a feature is one scaffold command plus one line in extends.
- BillingPolar checkout, customer portal and webhook behind an adapter — a Merchant of Record, so it works from countries Stripe won't onboard.
- 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.
- Accessibility, enforcedPlaywright and axe run every page in light and dark on a schedule. Contrast overrides live in one design-system layer.
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 real HTTP surface — so the claim
fails loudly the day someone widens a policy.
e2e/tenant-isolation.spec.ts
test('Globex cannot read Acme rows', async ({ page }) => {
await login(page, GLOBEX_ADMIN.email)
await page.goto('/notes')
await expect(page.getByText(ACME_SECRET_NOTE_TITLE)).toHaveCount(0)
})
Free and MIT licensed
Clone it, rename it with one command, and start building your product instead of
its scaffolding.