Skip to content
ExponaBLOG
VUE.JS & NUXT · DAY 092 / 100

Nuxt layers: what every Vue & Nuxt developer should know

Share apps and themes across projects.

Expona Admin8 min read

Opening

After 92 days of building with Vue and Nuxt, you've likely hit a moment where you need to reuse an entire project structure—components, composables, middleware, config—across multiple applications. Nuxt layers solve this elegantly by letting you inherit from a base Nuxt project, fork configurations, and override anything you need. This guide covers Nuxt layers: what every Vue & Nuxt developer should know, showing how to architect shared foundations for multi-brand sites, SaaS dashboards, and distributed teams.

What are Nuxt layers and why they matter

A Nuxt layer is a parent project that a child Nuxt app extends. Unlike npm packages, layers preserve the full Nuxt directory structure—pages, components, composables, middleware, plugins, and configuration all cascade down. The child project can override or extend anything from the parent, making layers perfect for teams maintaining multiple branded sites with shared logic.

Think of layers as a inheritance chain. You build a robust base layer with your core UI components, authentication flow, and data fetching patterns. Then each brand or tenant extends that layer, importing only what they need and customizing the rest.

Extend a base Nuxt layer

To extend a layer, declare it in your child project's nuxt.config.ts:

typescript
export default defineNuxtConfig({
  extends: ['../base-layer'],
  
  app: {
    head: {
      title: 'My Brand',
    }
  }
})

The parent layer can live locally (relative path), in a git repo, or published to npm. For git-based layers, use:

typescript
extends: [
  'github:myorg/nuxt-base-layer#main',
  'npm:@myorg/nuxt-theme'
]

Multiple layers stack in order. Each child layer inherits from all parents above it. Here's what happens behind the scenes:

  • Nuxt merges configuration objects recursively
  • Array fields (modules, buildModules) are concatenated
  • Components and pages are resolved in child-to-parent order; child files shadow parent files of the same name
  • Layouts, middleware, and plugins follow the same shadowing rules

Let's say your base layer has this structure:

typescript
// base-layer/
// ├── nuxt.config.ts
// ├── components/
// │   └── Button.vue
// ├── composables/
// │   └── useAuth.ts
// └── pages/
//     └── index.vue

// child-project/
// ├── nuxt.config.ts
// └── pages/
//     └── dashboard.vue

The child project automatically inherits Button.vue, useAuth() and the base index.vue, while adding its own dashboard route. No additional imports needed.

Share components and configuration

This is where layers shine for teams. Put all your design-system components in the base layer:

typescript
// base-layer/components/ui/
// ├── Button.vue
// ├── Card.vue
// ├── Modal.vue
// ├── Form/
// │   ├── Input.vue
// │   └── Select.vue

// These are auto-imported in any child project

Every child project can use these components without installing anything:

typescript
// child-project/pages/checkout.vue
<template>
  <Card>
    <h1>Complete Purchase</h1>
    <FormInput v-model="email" label="Email" />
    <Button @click="submit">Pay</Button>
  </Card>
</template>

For configuration, centralize shared settings in the base layer's nuxt.config.ts:

typescript
// base-layer/nuxt.config.ts
export default defineNuxtConfig({
  modules: [
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
    'nuxt-icon'
  ],
  
  runtimeConfig: {
    apiBase: 'https://api.example.com',
    public: {
      siteVersion: '1.0.0'
    }
  },
  
  tailwindcss: {
    config: {
      theme: {
        extend: {
          colors: {
            brand: '#2563eb'
          }
        }
      }
    }
  },
  
  ssr: true,
  experimental: {
    payloadExtraction: false
  }
})

The child merges and can override:

typescript
// child-project/nuxt.config.ts
export default defineNuxtConfig({
  extends: ['../base-layer'],
  
  runtimeConfig: {
    apiBase: 'https://api-us.example.com', // override
    public: {
      siteVersion: '1.0.1',
      brandName: 'Acme Co' // new key
    }
  },
  
  tailwindcss: {
    config: {
      theme: {
        extend: {
          colors: {
            brand: '#7c3aed' // different brand color
          }
        }
      }
    }
  }
})

Composables and utilities are inherited and used identically:

typescript
// base-layer/composables/useApi.ts
export const useApi = () => {
  const config = useRuntimeConfig()
  return $fetch.create({ baseURL: config.apiBase })
}

// child-project/pages/products.vue - no import needed
<script setup>
const api = useApi()
const products = await api('/products')
</script>

Great for multi-brand sites

This is the real-world win. Imagine you're building a SaaS platform with white-label support. You have core features—dashboards, reporting, user management—that all brands need, but each brand has:

  • Different logo, colors, and fonts
  • Custom branding in emails and exports
  • Unique feature flags and pricing tiers
  • Separate API endpoints per region

With layers, you structure it like this:

typescript
// monorepo/
// ├── packages/
// │   └── base-layer/          <-- Core SaaS features
// │       ├── components/      <-- Dashboard, forms, tables
// │       ├── pages/           <-- /dashboard, /reports
// │       ├── composables/     <-- Auth, API, data fetching
// │       ├── middleware/      <-- Auth checks
// │       └── nuxt.config.ts   <-- Shared config
// │
// ├── apps/
// │   ├── acme-brand/          <-- Extends base-layer
// │   │   ├── public/
// │   │   │   └── logo.svg
// │   │   ├── assets/
// │   │   │   └── colors.css
// │   │   └── nuxt.config.ts   <-- Acme branding
// │   │
// │   └── techcorp-brand/      <-- Extends base-layer
// │       ├── public/
// │       │   └── logo.svg
// │       ├── assets/
// │       │   └── colors.css
// │       └── nuxt.config.ts   <-- TechCorp branding

Each brand's config is simple and focused:

typescript
// apps/acme-brand/nuxt.config.ts
export default defineNuxtConfig({
  extends: ['../../packages/base-layer'],
  
  app: {
    head: {
      title: 'Acme Dashboard'
    }
  },
  
  css: ['~/assets/acme-theme.css'],
  
  runtimeConfig: {
    public: {
      brandName: 'Acme',
      apiBase: 'https://api-us.acme.com',
      supportEmail: 'support@acme.com'
    }
  }
})

Both brands reuse all dashboard logic, tables, and forms from base-layer. Bugs fixed in base-layer benefit everyone immediately. New features roll out to all brands at once—with brand-specific overrides where needed.

Override anything in the child

Layers are not just for inheritance; they're about selective overrides. If a child needs different behavior, it shadows the parent file entirely.

Say your base layer has a generic login page:

typescript
// base-layer/pages/auth/login.vue
<template>
  <div class="login-container">
    <h1>Sign In</h1>
    <FormInput v-model="email" label="Email" />
    <FormInput v-model="password" type="password" label="Password" />
    <Button @click="login">Sign In</Button>
  </div>
</template>

One brand wants OAuth and multi-factor authentication. They override it completely:

typescript
// apps/techcorp-brand/pages/auth/login.vue
<template>
  <div class="login-container">
    <h1>TechCorp Secure Login</h1>
    <OAuthProvider provider="google" @login="handleOAuth" />
    <OAuthProvider provider="microsoft" @login="handleOAuth" />
    <Divider>or</Divider>
    <FormInput v-model="email" label="Email" />
    <FormInput v-model="password" type="password" label="Password" />
    <Button @click="loginWithMfa">Sign In with 2FA</Button>
  </div>
</template>

<script setup>
const handleOAuth = async (provider) => {
  // TechCorp-specific OAuth flow
}

const loginWithMfa = async () => {
  // MFA logic
}
</script>

The same applies to composables. If a child needs a variant of a base composable:

typescript
// base-layer/composables/useFetch.ts
export const useFetch = async (endpoint) => {
  const api = useApi()
  return api(endpoint)
}

// child-project/composables/useFetch.ts - shadows the parent
export const useFetch = async (endpoint) => {
  const api = useApi()
  const response = await api(endpoint)
  
  // Child-specific caching or transformation
  return transformResponse(response)
}

You can also extend a parent component without fully replacing it using dynamic imports:

typescript
// child-project/components/Button.vue - wraps the base Button
<script setup>
import { Button as BaseButton } from '#build/components'

defineProps({
  variant: {
    type: String,
    default: 'primary'
  }
})
</script>

<template>
  <BaseButton :class="`btn-${variant}`">
    <slot />
  </BaseButton>
</template>

Common mistakes

Circular dependencies. If your base layer imports from a child, and the child extends the base layer, Nuxt will fail to build. Always keep parent layers independent of children.

Over-nesting layers. While multiple extends work, more than 2–3 layers deep becomes hard to reason about. Keep it simple: one base, multiple children. If you need more structure, use npm packages for utilities.

Forgetting to commit layer changes. If your layer lives in git, child projects pulling the latest might break. Use git tags or release versions for stability. For npm layers, use semantic versioning.

Assuming auto-import for everything. Only components, composables, and utils are auto-imported. Pages must exist as files in the pages/ directory to become routes. Plugins and middleware also need to be explicitly present in the directory structure.

Not handling environment differences. Different brands may deploy to different environments with different API endpoints. Use runtimeConfig and .env files per child project, not hardcoded values in the base layer.

Key takeaways

  • Nuxt layers let you extend an entire parent project, inheriting components, composables, pages, and config without npm installation.
  • Use layers for multi-brand sites, SaaS with white-label support, and distributed team workflows where teams share core logic but customize branding and features.
  • Declare layers in nuxt.config.ts via local path, git URL, or npm package; stack multiple layers for complex inheritance chains.
  • Child projects can override any parent file—pages, components, composables—by creating a file with the same path in their project.
  • Keep layers simple: one stable base layer, multiple child layers extending it. Use npm packages for low-level utilities instead of deep layer nesting.
  • Use runtimeConfig for environment-specific values like API endpoints and brand names; avoid hardcoding in the base layer.
  • Test layer integration early in your CI/CD pipeline to catch shadowing conflicts and missing dependencies.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles