Architecture
What the template includes
Authentication — Email/password login and signup with Supabase Auth. Global route middleware protects every page. Onboarding flow for first-time users. Invite-based account creation for team members.
Multi-tenancy — Teams are the data isolation boundary. Every row is scoped to a team via RLS policies. Users can belong to multiple teams and switch between them from the sidebar. A team-id cookie persists the selection.
Role-based access — Owner, admin, and member roles with a centralised
permissions system. One file (shared/permissions.ts) defines every role and
what it can do. The authUser server utility and useUserRole composable
both read from this map. The <CanAccess> component gates UI elements
declaratively and the requirePermission route middleware redirects before
navigation. See Permissions for full details.
Team management — Create teams, update name and avatar, configure webhook URLs, delete teams with type-to-confirm safety. All behind a clean settings UI built with Nuxt UI dashboard components.
Member management — Invite people by email, assign roles, change roles live, remove members with confirmation modals. Pending invitations section with copy-link and revoke actions.
Realtime — Supabase Realtime keeps everything in sync across all connected clients. Member list, invitations, team settings, announcements, chats, the activity log, and the sidebar all update instantly when another user (or another tab) makes a change. When a user is removed from their last team, they are redirected out immediately.
Webhooks — Every team mutation (invite created, member removed, role changed, team updated/deleted) fires a webhook to a configurable URL. Payloads include full context. See Invitations and Webhooks for details.
AI Chat — Baked-in AI assistant that queries the database in natural
language and performs writes through typed tool calls. Scoped to the current
team, gated per-table via the tablePermissions map, and run through a
sandboxed chat_reader Postgres role so RLS (not the LLM) decides visibility.
Writes are tagged actor_source='chat' in the activity log. Enabled when
OPENROUTER_API_KEY is set; the UI is hidden otherwise. See
AI Chat for full details.
Announcements — Owners and admins can publish banner announcements visible to everyone on the team. Configurable title, description, icon, color, link target, and CTA actions. Managed in Settings → Announcements.
Activity log — Every mutation on opted-in tables is captured by a
Postgres AFTER trigger — actor, source (api/chat/system), before/after
snapshot, and per-column diff. Visible at /app/activity to team owners only.
See Activity Log for how to opt new tables in.
Avatar uploads — Reusable upload component for both user profiles and team avatars. Handles upload, delete, and preview. Backed by Supabase Storage.
Confirmation modals — Two-tier destructive action protection. Standard actions get a confirm dialog. High-impact actions (like deleting a team) require typing a confirmation string.
Dashboard UI — Built entirely with Nuxt UI's dashboard components: collapsible/resizable sidebar, team switcher dropdown, navigation menu, command palette search, user profile dropdown with theme toggle. Responsive and polished out of the box.
Stack
- Nuxt 3 — Vue 3 with SSR, file-based routing, auto-imports
- Supabase — Auth, Postgres with RLS, Realtime, Storage
- Nuxt UI — Component library with dashboard layout primitives
- Vercel AI SDK — model-agnostic streaming, tool calls, typed inputs (used by the baked-in AI chat)
- TypeScript — End-to-end type safety with generated database types
Server architecture
All database writes go through Nitro server routes using the Supabase service role key. Vue components never call Supabase directly for mutations.
Why:
- RLS is your first layer of defence, but not your only one. Server routes give you a second layer you fully control.
- The anon key is public and exposed in the browser. With server routes, all sensitive operations require a valid session verified on your server first.
- Business logic (validation, permission checks, side effects) lives in one clear place instead of being scattered across components.
- The service role key bypasses RLS and must never be exposed to the browser. Keeping it exclusively in Nitro server routes makes it invisible to users.
The pattern: Vue components call your own API routes → your server verifies identity and permission → then performs the database operation using the service role key.
Audited client — server/utils/createAuditedClient.ts wraps the service
role key and attaches actor + team headers (x-actor-id, x-team-id,
x-actor-source) to every PostgREST request. The log_activity() trigger
reads those headers to attribute every mutation. authUser() / authUserOnly()
return this client by default. Chat-driven writes override source: 'chat'
and pass sourceRef: <chatId> so activity log rows link back to the chat
that caused them.
Deployment model: internal app vs SaaS
This template ships configured as an internal app: you build it for a specific business, you control who signs up, and kicked members cannot create their own parallel teams inside your app. The multi-tenancy (teams, roles, RLS) is still fully in place — it just behaves like "one organization, many members" rather than "many organizations, self-serve".
Flipping to SaaS
Flipping the template into a SaaS model (self-serve signup, anyone can
create a team, one codebase serving many unrelated businesses) is four small
edits. None of the architecture changes — teams remain the tenant boundary,
RLS policies remain team-scoped, authUser still enforces roles. You are only
relaxing the guardrails that make sense for an internal deployment.
1. Allow deleting your only team — server/api/teams/[teamId]/index.delete.ts
The internal app refuses to delete an owner's last team so they are not
stranded on /no-team. In SaaS that is fine because they can just create a new
one. Remove the otherTeamCount guard block.
2. Ungate "Create team" in the sidebar — app/components/layout/sidebar/Header.vue
The Create team menu item is gated behind the settings.team permission
(owner of the current team), because in an internal app a kicked member
spinning up their own shadow team would defeat the point of kicking them. In
SaaS, team creation is a user-level action — any authenticated user should be
able to create their own workspace. Remove the can("settings.team")
conditional so the menu item always renders.
3. Give /no-team a recovery path — app/pages/no-team.vue
The internal app treats /no-team as an unreachable-in-normal-flow dead end
and tells the user to contact their admin. In SaaS it is a legitimate state
(new signup who has not created a workspace yet, or someone who just left
their last team) and needs a "Create a team" button. Replace the sign-out
fallback with a primary button opening LayoutCreateTeamModal — the modal
already exists and the create flow already refreshes the JWT's has_team
claim, so no additional wiring is needed.
4. Decide on public signup
This is the one that is already SaaS-shaped: pages/auth/signup.vue is
publicly reachable and anyone with a valid email can create an account. For
SaaS, keep it. For a strict internal app where accounts only come into
existence via invitation, delete pages/auth/signup.vue and
components/auth/SignupForm.vue, remove the "Sign up" link from
components/auth/LoginForm.vue, and disable signups in the Supabase
dashboard (Authentication → Providers → Email → Disable signups). The
/invite/[token] flow handles invite-only account creation independently
and will keep working.
What you do not need to change
- RLS policies — already team-scoped, correct for both models.
- Permissions and
authUser— owner/admin/member roles work the same way in SaaS. The difference is just how many teams a user can belong to. - Onboarding auto-creating a first team — fine for both. SaaS users expect to land in a workspace; they can rename it afterward in settings.
- Invite flow, webhooks, avatar storage, team context resolution — all model-agnostic.
What SaaS additionally needs (not provided)
Out of scope for this template, but worth naming so you know what is missing:
billing/subscriptions (Stripe + a subscriptions table keyed to team_id),
seat/plan enforcement in middleware, per-team usage quotas, and email
verification enforcement (toggle in Supabase).
Public marketing pages: the template ships with a minimal public homepage at
/, and the global middleware gates only /app/* (plus /onboarding and
/no-team). Anything else at the top level — /pricing, /about,
/blog/*, /legal/* — is public by default. Add files under
app/pages/ and they render unauthenticated; the marketing surface is
already wired up.