"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.
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.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.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.shared/ into a dumping ground is the usual decay; review it as an API.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.future.multiApp prepares for multiple apps on one page but is not a micro-frontend framework.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.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)
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:
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:
packages:
- apps/*
- layers/*
- packages/*
catalog:
nuxt: ^4.5.2
vue: ^3.5.43
{
"dependencies": {
"nuxt": "catalog:",
"@team/layer-ui": "workspace:*",
"@team/layer-auth": "workspace:*",
"@team/schemas": "workspace:*"
}
}
export default defineNuxtConfig({ extends: ['@team/layer-ui', '@team/layer-auth'] })
{
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": [".output/**", "dist/**"] },
"typecheck": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["^build"] }
}
}
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.
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.
pnpm catalog syntax, Turborepo's current task configuration keys, and whether eslint-plugin-boundaries supports ESLint 10 flat config without adapters.
components.dirs and imports.dirs; confirm auto-imports still resolve."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Component architecture and design systems
Layering components from tokens to pages, headless primitives plus styled wrappers, designing props and slots APIs that scale across a team, the accessibility contract, documentation, versioning and deprecation, distribution as a package or a Nuxt layer, theming for brands, governance, and build versus adopt.
Rendering and data architecture
Deciding rendering per route with a criteria table, the caching layers from browser to database, server-side aggregation versus client waterfalls, realtime and auth architecture, multi-tenant theming and i18n routing, search, edge versus region placement, resilience, observability as a design input, and migration paths.