Architecture
Ten layers, one shell.
Every feature is a self-contained Nuxt layer over a shared foundation. Adding one is a directory and a line; removing one is deleting the directory. Nothing in between reaches across.
01 — The shape
Features are layers, not folders
The root project is the shell — app.vue, layouts, auth pages, stores, base components and the core server routes. Everything else is a layer it extends.
Root — the shell
app.vue · layouts · auth pages + global middleware · Pinia stores · shared/types via #shared
layers/ui
Design system — tokens, fonts, brand chrome
layers/marketing
Public pages: landing, pricing, legal
layers/notes
Reference CRUD feature
layers/admin
Tenant administration
layers/account
Profile and settings
layers/billing
Polar checkout, portal, webhooks
layers/email
Transactional mail + the shared shell
layers/analytics
PostHog, feature flags
layers/feedback
Self-hosted widget → your own DB
layers/tour
First-run onboarding
~ and @ always resolve to the root app, never the current layer — so layers rely on auto-imports rather than importing across each other. That is the rule that keeps them detachable.
02 — Adding a feature
One command, one line
pnpm gen:layer scaffolds the directory and its nuxt.config.ts. You add it to extends. That is the whole ceremony.
pnpm gen:layer reportsexport default defineNuxtConfig({
extends: [
'./layers/ui',
'./layers/marketing',
'./layers/reports',
],
}) Components, composables, utils, Pinia stores and Nitro server/utils all auto-import across layers. Translations stay centralised in the root i18n/locales/*, because a per-layer message catalogue is how key parity dies.
03 — Request lifecycle
Authorization happens in Postgres
By the time a component sees data, the database has already filtered it. The middleware exists to send people to the right page, not to protect rows.
- 01Request
SSR request hits Nitro. nuxt-security sets CSP with a per-request nonce.
- 02Middleware
auth.global.ts guards every non-public route and reads definePageMeta({ roles }).
- 03Session
@nuxtjs/supabase resolves the session from cookies. Module redirects are off — the middleware owns routing.
- 04Query
The composable queries Supabase as the caller. RLS scopes rows to their tenant in Postgres.
- 05Render
The page renders rows the database already decided it was allowed to see.
04 — Type pipeline
The schema is the source of types
No ORM, no hand-written row interfaces. pnpm db:types regenerates from the live local database, and a query that under-selects fails at typecheck instead of in production.
import type { Note } from '#shared/types'
// useAsyncData for reads, async mutators
// that refresh() after writing. Owner
// scoping is RLS's job, not this file's.05 — Deliberately deferred
What this stack refuses to add yet
A starter is judged as much by what it leaves out. These are written down with their trade-offs, not silently omitted.
ADR-0002
No ORM
Types generated from the schema already give end-to-end safety. Drizzle waits until server-side query complexity actually demands it.
Read the decisionADR-0005
No monorepo
One deployable, so a workspace would be overhead. In-repo layers are not a monorepo — they need no tooling at all.
Read the decisionADR-0001
Polar, not Stripe
A Merchant of Record, behind an adapter. Stripe will not onboard sellers in every country the author included.
Read the decisionIsolation is the other half of the story.
How RLS, roles and the layers above it actually keep tenants apart.