---
canonical_url: "https://gstack.goranninkovic.com"
---
<u-page-hero className="grid-surface" orientation="horizontal">
<template v-slot:headline="">

Free · MIT · Nuxt 4 · Supabase · Postgres RLS

</template>

<template v-slot:title="">

Ship multi-tenant SaaS, not scaffolding.

</template>

<template v-slot:description="">

A free, MIT-licensed 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.

</template>

<template v-slot:links="">
<u-button to="https://gstack.goranninkovic.com/docs/getting-started/installation" trailing-icon="i-lucide-arrow-right" size="xl">

Get started

</u-button>

<u-button to="https://github.com/TerrorSquad/gstack" variant="subtle" size="xl" color="neutral" icon="i-simple-icons-github" target="_blank">

View on GitHub

</u-button>
</template>

```bash
gh repo create my-app --template TerrorSquad/gstack --private --clone
cd my-app            # or hit "Use this template" on GitHub

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
```

</u-page-hero>

<u-page-section headline="01 — The guarantee">
<template v-slot:title="">

The isolation test

</template>

<template v-slot:description="">

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.

</template>

```ts [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.

<u-button to="https://gstack.goranninkovic.com/docs/architecture/multi-tenancy" label="How multi-tenancy works" trailing-icon="i-lucide-arrow-right" variant="link">



</u-button>
</u-page-section>

<u-page-section headline="02 — What it looks like">
<template v-slot:title="">

The screens you don't have to build

</template>

<template v-slot:description="">

Dashboard, notes CRUD, tenant admin with invites and impersonation, billing,
account with avatar upload, pricing, changelog and a feedback widget — all in
the template on the first run, in both themes and on mobile.

</template>

<u-button to="https://gstack.goranninkovic.com/screens" label="See the screens" trailing-icon="i-lucide-arrow-right" size="lg">



</u-button>
</u-page-section>

<u-page-section headline="03 — Batteries included">
<template v-slot:title="">

Every subsystem is one env flag

</template>

<template v-slot:description="">

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.

</template>

<table>
<thead>
  <tr>
    <th>
      Subsystem
    </th>
    
    <th>
      Flag
    </th>
    
    <th>
      Needs an account
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Supabase — Postgres, Auth, RLS, Storage
    </td>
    
    <td>
      <em>
        core, always on
      </em>
    </td>
    
    <td>
      No (local via Docker)
    </td>
  </tr>
  
  <tr>
    <td>
      Feedback widget → your own DB
    </td>
    
    <td>
      <code>
        NUXT_PUBLIC_FEEDBACK_ENABLED
      </code>
    </td>
    
    <td>
      No
    </td>
  </tr>
  
  <tr>
    <td>
      Billing — checkout, portal, webhooks
    </td>
    
    <td>
      <code>
        NUXT_PUBLIC_BILLING_ENABLED
      </code>
    </td>
    
    <td>
      Polar
    </td>
  </tr>
</tbody>
</table>

Six more — OAuth, notifications, analytics, the onboarding tour, error tracking
and log forwarding — are on <u-button to="https://gstack.goranninkovic.com/stack" label="the stack page" variant="link" className="!p-0">



</u-button>

.

`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.

<u-button to="https://gstack.goranninkovic.com/docs/getting-started/configuration" label="Configuration reference" trailing-icon="i-lucide-arrow-right" variant="link">



</u-button>
</u-page-section>

<u-page-section headline="04 — Proof, not adjectives">
<template v-slot:title="">

What CI won't let you break

</template>

<template v-slot:description="">

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.

</template>

<table>
<thead>
  <tr>
    <th>
      Gate
    </th>
    
    <th>
      What it catches
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        pnpm lint
      </code>
    </td>
    
    <td>
      oxlint + oxfmt, auto-fixing on pre-commit
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pnpm lint:i18n
      </code>
    </td>
    
    <td>
      a key added to <code>
        en
      </code>
      
       but not <code>
        sr
      </code>
      
      , or a key nothing uses
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pnpm test
      </code>
    </td>
    
    <td>
      logic + the email-shell drift test: auth templates that no longer match their generator
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pnpm typecheck
      </code>
    </td>
    
    <td>
      a query that under-selects, against types generated from the live schema
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pnpm test:e2e
      </code>
    </td>
    
    <td>
      tenant isolation, auth flows, notes CRUD
    </td>
  </tr>
  
  <tr>
    <td>
      axe, twice daily
    </td>
    
    <td>
      contrast and landmark failures, on every page, in <strong>
        both
      </strong>
      
       light and dark
    </td>
  </tr>
</tbody>
</table>

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.

</u-page-section>

<u-page-section headline="05 — What else is in the box" :features="[{"title":"Type-safe end to end","description":"Postgres schema → generated types → composables → UI. No ORM. Change a column and the build tells you every call site that cared.","icon":"i-lucide-shield-check"},{"title":"Auth that survives SSR","description":"Email/password plus GitHub and Google OAuth, password reset, confirmation, and a role-aware global middleware instead of the Supabase module's redirect.","icon":"i-lucide-key-round"},{"title":"One email shell","description":"Transactional 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.","icon":"i-lucide-mail"},{"title":"Bilingual from the start","description":"English and Serbian, with key parity enforced in CI. Dynamic keys are written so the usage checker can still resolve them.","icon":"i-lucide-languages"},{"title":"Portable deploy","description":"Nitro output, so Vercel is the default and not a lock-in. Supabase runs locally in Docker for development and CI alike.","icon":"i-lucide-cloud"},{"title":"Decisions on the record","description":"The reversible-but-significant choices — Polar over Stripe, no ORM yet, layers over a monorepo — are written down as ADRs with their trade-offs.","icon":"i-lucide-file-text"}]" description="Ten independent Nuxt layers over a shared design system, and the unglamorous work that usually gets deferred until it hurts." title="The parts you would otherwise build twice">



</u-page-section>

<u-page-section headline="06 — In the wild">
<template v-slot:title="">

Someone built a real product on it in 13 days

</template>

<template v-slot:description="">

[job-finder](https://goranninkovic.com/projects/job-finder) is a multi-tenant
product that reads company ATS boards, scores every posting against your CV with
an LLM, and drafts the application material. Tenancy, auth, billing, email and
the admin surface came from GStack on day one, so the 13 days went on the part
that was actually the product.

That is the whole claim being made here: not that the starter is clever, but
that the work it removes is work you would otherwise do before writing a line
of your own.

</template>
</u-page-section>

<u-page-section :links="[{"label":"Read the docs","to":"/docs/getting-started/introduction","size":"xl","trailingIcon":"i-lucide-arrow-right"},{"label":"Browse the decisions","to":"/docs/reference/decisions","size":"xl","color":"neutral","variant":"subtle"}]">
<template v-slot:title="">

Free and MIT licensed

</template>

<template v-slot:description="">

Clone it, rename it with one command, and start building your product instead of
its scaffolding.

</template>
</u-page-section>

## Resources for Agents

- [API catalog: every service document this site publishes](https://gstack.goranninkovic.com/.well-known/api-catalog)
- [Sitemap (XML)](https://gstack.goranninkovic.com/sitemap.xml)
- [Sitemap (Markdown): every page on the site](https://gstack.goranninkovic.com/sitemap.md)
- [MCP server card: MCP endpoint at https://gstack.goranninkovic.com/mcp](https://gstack.goranninkovic.com/.well-known/mcp/server-card.json)
- [MCP endpoint (streamable HTTP)](https://gstack.goranninkovic.com/mcp)
- [llms.txt: index of the documentation for LLMs](https://gstack.goranninkovic.com/llms.txt)
- [llms-full.txt: the full documentation as a single file](https://gstack.goranninkovic.com/llms-full.txt)
- [OpenAPI document: every route this site serves to agents](https://gstack.goranninkovic.com/openapi.json)


## Sitemap

See the full [sitemap](https://gstack.goranninkovic.com/sitemap.md) for all pages.
