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 root i18n/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}`)