Modules and plugins

Add your own Nuxt modules and plugins to a theme — where they go, how they compose with the ones the base layers ship, why build-time configuration is not the same as runtime configuration, and why there is nothing to ask permission for.

A theme is a Nuxt 4 application you own. The modules array in its nuxt.config.ts is yours, app/plugins/ is yours, and there is no allowlist to clear before you add to either.

That is worth stating plainly, because it is the question every Nuxt developer asks in their first week and the answer sounds too easy. The reason it is that easy: the platform runs your install and build commands against your repository. Whatever you put in modules or app/plugins/ is compiled by the same npm run build you run locally, and nothing in that pipeline vets it.

The one line that matters

Build-time extensibility exists. Runtime injection does not.

What you want to doSupported
Add a module or plugin to your own theme repo, compiled by your own buildYes — this page
Inject a plugin into an already deployed site, or a theme you don't ownNo — there is no such hook

If you're looking for a way to push a script into a running site without a rebuild, stop looking: it isn't a permissions problem you can solve, it's a feature that doesn't exist. Every change to a theme's code reaches production the same way — commit, build, deploy.

For the things you can change without a rebuild, see design tokens for branding and service modes for where a data domain reads from.

Adding a module

Modules go in the modules array, exactly as in any Nuxt project. The reference template ships five of its own on top of the base layer:

nuxt.config.ts
export default defineNuxtConfig({
  extends: ['@revenexx/cover'],

  modules: [
    '@scayle/nuxt-opentelemetry',
    '@nuxt/ui',
    '@nuxt/eslint',
    '@pinia/nuxt',
    '@nuxtjs/i18n',
  ],
})

Note the first entry. @scayle/nuxt-opentelemetry is a third-party module, not one of ours, sitting in the template we ship — which answers the "may I add a module that isn't yours?" question by example rather than by policy.

Two rules apply, and both come from the build rather than from us:

  • It has to resolve from the public registry. The build installs your dependencies with your install command, so a module from a private registry or a file: path won't be there. Commit your lockfile.
  • Config is merged, not replaced. Your modules array adds to what the base layers already registered; you are never restating theirs. Same for the rest of nuxt.config.ts — see the layer chain.

Adding a plugin

Plugins go in app/plugins/*.ts, with Nuxt's ordinary suffixes:

text
app/plugins/analytics.client.ts     ← browser only
app/plugins/request-context.server.ts  ← server only
app/plugins/error-reporting.ts      ← both
app/plugins/analytics.client.ts
export default defineNuxtPlugin((nuxtApp) => {
  nuxtApp.hook('page:finish', () => {
    // your own instrumentation, per navigation
  })
})

How yours compose with the layers'

The base layers already use this mechanism, which is the clearest evidence that it's the supported one. @revenexx/cover ships five plugins:

PluginWhat it does
auth-init.client.tsInitialises the auth store, then restores the cart from the server. In that order, because session expiry has to clear the cart first.
auth-session.server.tsReads the session cookie on the server and hydrates the auth store, so the first render already knows whether a buyer is signed in.
formkit-locale.client.tsKeeps FormKit's built-in validation messages in sync with the active locale.
model-viewer.client.tsRegisters the <rx-model-viewer> custom element. Client-only, because it renders WebGL.
safari-keyboard-nav.client.tsPatches missing tabindex onto links and comboboxes after each navigation.

Read that list as a menu of patterns: a store to warm up, a cookie to read server-side, a browser-only element to register, a DOM fix to apply per navigation. Your own plugins will look like one of these.

A theme extending @revenexx/cover-theme inherits three more — branding.ts, nav-menus.ts and appearance-defaults.ts — which resolve the tenant's logo, navigation and appearance settings once per request.

Your plugins run alongside these, not instead of them. Nuxt collects plugins from every layer in the chain, so adding app/plugins/analytics.client.ts doesn't displace anything.

Two consequences:

  • Registration order follows the filename, alphabetically within a directory, and layers are collected base-first — so every plugin the layers ship has already run by the time yours does. Within your own app/plugins/, order two of your files with a numeric prefix (01.setup.ts) or dependsOn in the object form of defineNuxtPlugin. Neither reaches a layer's plugin: dependsOn matches on a name that only the object form sets, and the layer plugins are all function-form, so naming one is silently ignored.
  • Shadowing does not apply to plugins. This is the trap. Naming your file app/plugins/branding.ts does not replace cover-theme's branding.ts — Nuxt de-duplicates plugins by resolved file path, and two layers give two different paths, so both run. Route middleware behaves the opposite way, de-duplicating by name, which is where the expectation comes from.
You cannot switch off a layer's plugin by putting a file at the same path. To change what one does, change what it reads — the server route it fetches, the app.config key it consults — or shadow that instead. See overriding the base layer, which covers the components, composables, routes and layouts where path shadowing genuinely works.

Build-time and runtime are different things

This is the constraint partners actually trip over, and it costs an afternoon when it bites.

A module's options in nuxt.config.ts are evaluated when the theme is compiled. Environment variables are read when the server runs. A value that gates whether code is included in the bundle at all cannot be switched on later by setting a variable on the deployed site — there is nothing left to switch on.

The template already contains the example. The OpenTelemetry module's enabled option is a build-time gate: when it's false the module strips its instrumentation out of the build entirely.

nuxt.config.ts
export default defineNuxtConfig({
  opentelemetry: {
    // BUILD-TIME: false removes the instrumentation from the bundle.
    enabled: process.env.NODE_ENV === 'production',
    pathBlocklist: '(^/(_nuxt|__nuxt|cdn)/)|(^/favicon\\.ico$)|(^/healthz$)',
  },
})

Whether traces are actually exported is the runtime half, and it's set per site deploy:

Bash
OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.your-collector.example
OTEL_SERVICE_NAME=acme-storefront
Get the two halves the wrong way round and the failure is silent. A production build with enabled: false emits nothing no matter what OTEL_* variables the site carries, because the instrumentation was never compiled in. Build-time first, runtime second.

Be careful with the runtime half: this module starts its exporter unconditionally once it is compiled in. Leaving OTEL_* unset does not make it inert — the SDK falls back to a local default endpoint and logs connection errors against something that is not there. If you want it compiled in but silent, set OTEL_SDK_DISABLED=true; if you want it gone, that is the enabled gate above. The same OTEL_* variables for an App are documented in Tracing.

The same split explains a choice inside the base layer worth copying. All three of cover-theme's plugins publish what they fetch into per-request useState rather than into app.config, because the tenant is a per-request fact while app.config is a build-time singleton. One deployment serves many tenants, so anything tenant-specific has to be resolved at request time, and a failed fetch publishes nothing, so the shipped defaults keep applying. Read each one before copying it: nav-menus treats an empty list as a deliberate choice and publishes it, which switches a nav group off rather than restoring the default.

If you write a plugin that reads anything tenant-specific, copy that shape. app.config is a server-wide singleton: a plugin writing a tenant's value into it at request time leaks that value to every other tenant being served concurrently. See multi-tenant themes.

A module can be production-only, for a good reason

The template deliberately disables OpenTelemetry under nuxt dev, and the reason generalises.

The template's own comment records why: with the module active under nuxt dev, its request instrumentation throws on the URLs the dev server produces and every page 500s. Take that as the template author's account of a real failure rather than a mechanism to reason from — the versions named in the comment and in the module's own dependencies do not agree, so the precise cause is not settled. Gating it on NODE_ENV === 'production' is what keeps the dev server usable, and it is tidier than dropping the module from modules[] when you want it off.

The pattern to take from it: when a module misbehaves in dev but is needed in production, gate its enabled option rather than removing the module or debugging the dev server. Leave a comment saying why, so the next developer doesn't "fix" it by turning it back on.

And check the inverse before you ship: npm run build && npm run preview runs the real production server, which is the first place a production-only module executes on your machine. A module that is off in dev has never been exercised by your dev loop.

SSR compatibility

A theme is server-rendered — the template sets it explicitly:

nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/**': { ssr: true },
  },
})

So a module or library that touches window, document or localStorage at import time will break the server render rather than degrading. Two ways out, in order of preference:

SituationDo this
A browser-only library you call yourselfPut it in a .client.ts plugin, or import it inside onMounted
A module that has no server halfRegister it, and keep its work client-side via its own options
A whole route that genuinely can't server-renderrouteRules: { '/that-route': { ssr: false } } — last resort

Reach for the route rule last. Price, stock and the buyer's session are per-request facts, which is why the theme server-renders in the first place; a client-only route gives that up for the whole page. Rendering covers the trade-off properly.

Bundle size is the other thing to watch. Modules fall into three groups, not two: build-time only (@nuxt/eslint adds nothing to the output), server-only, and those that ship code to the browser. A storefront's first paint is a commercial metric, so check the build output rather than assuming which group a module is in.

Why there is nothing to permit

Worth closing the loop, because "surely there's an approval step" is the natural assumption.

A theme declares how it is built in its own manifest:

theme.json
{
  "site": {
    "framework": "nuxt",
    "adapter": "ssr",
    "installCommand": "npm install",
    "buildCommand": "npm run build",
    "outputDirectory": ".output"
  }
}

The platform installs your dependencies and runs your build command, then serves what your build produced. Your modules and plugins are inputs to that build, on the same footing as your components. There is no separate registry of permitted modules, because there is no point in the pipeline where one would be consulted.

What the platform does gate is in theme.json too, and it's about data rather than code: requires and permissions declare the capabilities and entities your theme may reach. See Theme anatomy.

Was this page helpful?