Architecture & system design

Application structure and boundaries

Organising a large Nuxt codebase — by type versus by feature and how Nuxt's scanning constrains that, enforced dependency direction with lint rules, the shared kernel, server services versus handlers, monorepos with pnpm workspaces and Turborepo, Nuxt layers as the unit of reuse, and when micro-frontends are justified.

"Our Nuxt app has 400 components in one folder" is a real prompt, and the answer is not a folder rename. Structure is about boundaries: what may depend on what, who owns each part, and how the tooling enforces it. Nuxt's conventions help and constrain; layers and monorepos extend them; micro-frontends are the option to reach for last.

Know

  • By type versus by feature. Nuxt's default is by type (components/, composables/, pages/, server/api/), which works to a few dozen components and then hides which files belong together. Feature folders (app/features/checkout/{components,composables,stores,utils}) group by domain. Nuxt scans components/ recursively and only the top level of composables/, and pages must stay in pages/, so feature folders are wired with components.dirs (with a prefix), imports.dirs for composables and utils, and pages:extend or layers when pages should live with features.
  • Dependency direction: features may depend on shared kernels (shared/, a UI layer, app/lib), never on each other's internals; cross-feature communication goes through a feature's public index.ts, events, or the store. On the server, handlers depend on services, services on repositories and shared schemas, nothing depends on handlers.
  • Enforcing boundaries with tooling, not code review alone: eslint-plugin-import-x's no-restricted-paths, eslint-plugin-boundaries, or dependency-cruiser with rules ("features may not import other features' internals", "shared may not import Vue or Nitro"), plus TypeScript project references (Nuxt already splits app, server and shared) and CODEOWNERS per feature.
  • The shared kernel is small and stable: schemas, DTO types, formatting utilities, error types, constants. Growth of shared/ into a dumping ground is the usual decay; review it as an API.
  • Nuxt layers as the unit of reuse: a UI layer (tokens, primitives, config preset), domain layers (auth pages plus server routes plus composables), and app layers that assemble them; layers ship pages and server routes together, which packages cannot, and consumers override by file path. The layers section has the mechanics; architecturally, layers are how one team shares conventions across several Nuxt apps without a platform team.
  • Monorepos: pnpm workspaces (apps/*, packages/*, layers/*), workspace:* dependencies, a shared tsconfig and ESLint config package, Turborepo or Nx for task graphs and remote caching (build depends on ^build), Changesets for versioning published packages, pnpm --filter for targeted runs. Internal packages are consumed from source in dev (exports pointing at src in dev conditions) or built; keep one Vue and one Nuxt version across the workspace to avoid duplicate instances.
  • Micro-frontends: independently deployed fragments composed at runtime (Module Federation via the Vite plugin, iframes, web components, or edge composition). Justified when several teams must deploy on independent cadences and the organisational cost of coordination exceeds the technical cost; the costs are duplicated dependencies and design drift, cross-fragment routing and auth, shared state, performance (multiple Vue runtimes), and testing. For most Nuxt organisations, layers plus a monorepo plus route-level ownership deliver team autonomy without runtime composition; Nuxt's future.multiApp prepares for multiple apps on one page but is not a micro-frontend framework.
  • Naming and colocation: feature-prefixed component names (CheckoutSummary), tests next to code or mirrored under test/, one component per file, index files as public APIs, README.md per feature with its boundaries.
  • Evolution path: extract a feature folder from the type-based tree; when two apps need it, move it into a layer; when non-Nuxt consumers need it, a package; when teams need independent deploys, separate apps sharing layers. Each step is reversible until runtime composition.

How it works

Feature folders in a Nuxt 4 app, wired through configuration:

app/
├── features/
│   ├── checkout/
│   │   ├── components/   CheckoutSummary.vue, CheckoutAddressForm.vue
│   │   ├── composables/  useCheckout.ts
│   │   ├── stores/       checkout.ts
│   │   ├── index.ts      public API: export { useCheckout } from './composables/useCheckout'
│   │   └── README.md     boundaries, owners
│   └── catalog/ ...
├── pages/                 thin: import feature components, no logic
├── layouts/
└── lib/                   app-wide non-domain helpers
shared/                    schemas, DTOs, constants (no Vue, no Nitro)
server/
├── api/                   handlers (thin)
├── services/              domain logic, takes db as a parameter
└── utils/                 auto-imported helpers (useDb, requireRole)
nuxt.config.ts
export default defineNuxtConfig({
  components: [
    { path: '~/features/checkout/components', prefix: 'Checkout' },
    { path: '~/features/catalog/components', prefix: 'Catalog' },
    '~/components',
  ],
  imports: { dirs: ['features/*/composables', 'features/*/stores', 'lib'] },
  hooks: {
    'pages:extend'(pages) {
      // feature-owned pages can be registered here when they should live with the feature
      pages.push({ name: 'checkout', path: '/checkout', file: '~/features/checkout/pages/index.vue' })
    },
  },
})

Boundaries enforced by lint, so they survive the next hire:

eslint.config.mjs
import withNuxt from './.nuxt/eslint.config.mjs'
import boundaries from 'eslint-plugin-boundaries'

export default withNuxt({
  plugins: { boundaries },
  settings: {
    'boundaries/elements': [
      { type: 'feature', pattern: 'app/features/*', capture: ['name'] },
      { type: 'shared', pattern: 'shared/*' },
      { type: 'lib', pattern: 'app/lib/*' },
      { type: 'pages', pattern: 'app/pages/*' },
      { type: 'server', pattern: 'server/*' },
    ],
  },
  rules: {
    'boundaries/element-types': ['error', {
      default: 'disallow',
      rules: [
        { from: 'feature', allow: ['shared', 'lib', ['feature', { name: '${from.name}' }]] }, // a feature may import itself, shared and lib
        { from: 'pages', allow: ['feature', 'shared', 'lib'] },
        { from: 'server', allow: ['shared', 'server'] },
        { from: 'shared', allow: ['shared'] },
      ],
    }],
  },
})

A monorepo that shares layers and packages across two Nuxt apps:

pnpm-workspace.yaml
packages:
  - apps/*
  - layers/*
  - packages/*
catalog:
  nuxt: ^4.5.2
  vue: ^3.5.43
apps/shop/package.json (excerpt)
{
  "dependencies": {
    "nuxt": "catalog:",
    "@team/layer-ui": "workspace:*",
    "@team/layer-auth": "workspace:*",
    "@team/schemas": "workspace:*"
  }
}
apps/shop/nuxt.config.ts
export default defineNuxtConfig({ extends: ['@team/layer-ui', '@team/layer-auth'] })
turbo.json
{
  "tasks": {
    "build": { "dependsOn": ["^build"], "outputs": [".output/**", "dist/**"] },
    "typecheck": { "dependsOn": ["^build"] },
    "test": { "dependsOn": ["^build"] }
  }
}
Gotcha· Two Vue versions in one workspace

A layer pinning vue@3.5.40 and an app on 3.5.43 can resolve to two Vue instances, and inject, provide and reactivity stop working across the boundary with baffling errors. Use a pnpm catalog or overrides to keep one version, and nuxt upgrade --dedupe after upgrades.

Gotcha· Micro-frontends as an organisational shortcut

Teams adopt runtime composition to avoid coordinating releases and then spend the saved time on shared dependencies, design drift and cross-fragment auth. If the teams already share a repository and a design system, layers and route ownership give the autonomy at a fraction of the cost.

Verify before the interview:

pnpm catalog syntax, Turborepo's current task configuration keys, and whether eslint-plugin-boundaries supports ESLint 10 flat config without adapters.

docs ↗

Exercise

Exercise
  • Convert one type-based Nuxt app to feature folders for a single feature, wiring components.dirs and imports.dirs; confirm auto-imports still resolve.
  • Add the boundaries lint rules and let them find every cross-feature import in a codebase you own.
  • Create a workspace with one UI layer and two apps extending it; change a token in the layer and see both apps update.

Be able to say

Be able to say· A Nuxt app has 400 components in one folder and every change touches five teams. What do you do?

"Draw boundaries before moving files. I would identify the domains, create feature folders that hold each domain's components, composables, stores and server services, wire them into Nuxt with components.dirs prefixes and imports.dirs, keep pages thin, and give each feature a public index.ts and an owner in CODEOWNERS. Then I enforce the direction with lint rules: features may import shared code and their own internals, never another feature's internals; shared imports nothing from features or frameworks; server handlers depend on services, not the reverse. When a second app needs a domain, it becomes a Nuxt layer in a pnpm workspace with Turborepo caching, and only if teams truly need independent deploy cadences would I consider runtime composition, because micro-frontends cost duplicated dependencies, design drift and cross-fragment auth."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.