Architecture
Layers
How features are organised, and why ~ never means what you think.
The root project is the shell: app.vue, layouts, auth pages, stores, base
components, shared utils and the core server/. Every feature is a Nuxt Layer
the root extends.
layers/
├── ui design system — theme tokens, AA overrides, fonts, brand chrome
├── marketing landing, pricing, privacy, terms
├── notes the reference CRUD
├── admin user list, invite, set-role, ban, delete, impersonate
├── account self-serve account deletion
├── billing Polar checkout, portal, webhook
├── email templates + /dev/emails preview
├── feedback self-hosted in-app widget
├── tour driver.js first-run product tour
└── analytics PostHog pageviews + useFeatureFlag()
Adding a feature is a new layers/<name>/ with its own nuxt.config.ts, listed in
the root extends — or just pnpm gen:layer <name>.
The one rule that bites
~ and @ always resolve to the root app, never the current layer.
::From inside a layer, don't import from '~/…'. Rely on auto-imports instead —
components, composables, utils, stores and Nitro server/utils all auto-import
across layers. Shared types live in shared/types/ and are imported via #shared:import type { Note } from '#shared/types'
The design-system layer
layers/ui is the single source of truth for theme tokens, AA contrast overrides,
Nuxt UI defaults, fonts and brand chrome. Every app extends it, so a future
marketing/app/docs split can't drift into three slightly different indigos.Don't put theme values anywhere else.i18n stays central
Locales live in the rooti18n/locales/*.json even though features are layered —
en is the default, sr the alternate. Add every key to both files and run
pnpm lint:i18n, which checks key parity and that every key is actually used.Build dynamic keys with template literals so the usage checker can resolve them:const label = t(`common.role.${role}`)