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.

terminal
pnpm gen:layer reports
nuxt.config.ts
export 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.

  1. 01Request

    SSR request hits Nitro. nuxt-security sets CSP with a per-request nonce.

  2. 02Middleware

    auth.global.ts guards every non-public route and reads definePageMeta({ roles }).

  3. 03Session

    @nuxtjs/supabase resolves the session from cookies. Module redirects are off — the middleware owns routing.

  4. 04Query

    The composable queries Supabase as the caller. RLS scopes rows to their tenant in Postgres.

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

1Postgres schema
2supabase gen types
3shared/types/database.types.ts
4composables (#shared)
5components
layers/notes/app/composables/useNotes.ts
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.

Isolation is the other half of the story.

How RLS, roles and the layers above it actually keep tenants apart.

Read about security