Modules, plugins and layers
Add a feature with a module at build time, run your own code at app start with a plugin, and share a whole Nuxt setup between projects with a layer.
What you'll learn
When you finish, you can do these.- 1Add a module that turns Markdown files into pages
- 2Write a plugin that gives every page a price formatter
- 3Share a footer and a page between sites with a layer
- 4Keep a browser-only plugin off the server
The Corner Shop, the small online shop this track builds, needs four things from its site. The owner wants to write the shop's blog, the journal, in Markdown: plain text with formatting marks. The product photos arrive as 4 MB files, too heavy for a phone. Every price needs the same pound format, and the owner's bakery needs a site of its own with the shop's footer and legal page.
Nuxt has one tool for each kind of job: a module for a feature someone else built, a plugin for your own code that runs as the app starts, and a layer for a setup two sites share. The Code tab holds the shop's files with all three at work. You need one idea from the lesson Rendering modes and deployment: nuxt build runs once and makes a server, which renders a page for each request; the browser then starts the same app on that page.
Modules add features at build time
A module is a package someone else built that runs when Nuxt starts or builds, before any page exists. It adds components, composables, routes or config, and your pages use them with no import line. One command installs Nuxt Content, the module behind the journal:
npx nuxt module add content
It installs the package and lists it under modules in nuxt.config.ts.
- npx nuxt module add
- modules in nuxt.config
- buildthe module adds components, composables, routes
- auto-imported in your app
The shop's nuxt.config.ts lists three modules: Content for the journal, Image for the photos and Fonts for the shop's font.
modules: ['@nuxt/content', '@nuxt/image', '@nuxt/fonts'],
Nuxt Content turns the Markdown files in content/ into collections, groups of files a page can ask for by name. Each journal post is one file in content/journal/, and content.config.ts declares the journal collection with the fields each post must have, described with z from Zod, a library that checks data:
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { z } from 'zod'
export default defineContentConfig({
collections: {
journal: defineCollection({
type: 'page',
source: 'journal/*.md',
schema: z.object({ date: z.string() }),
}),
},
})
The post page, app/pages/journal/[slug].vue, answers every address under /journal/: the brackets stand for any post name. It finds the post whose path matches the URL, and a path comes from the file name: hello-nuxt.md is /journal/hello-nuxt.
<script setup lang="ts">
const route = useRoute()
const { data: post } = await useAsyncData(route.path, () =>
queryCollection('journal').path(route.path).first(),
)
</script>
queryCollection() comes from the module, and so does <ContentRenderer>, which the page's template, in the Code tab, uses to turn the Markdown into HTML.
Content keeps the parsed files in SQLite, a one-file database. sqliteConnector: 'native' in nuxt.config.ts uses the one built into Node 22.5 and later; without it, the first start asks you to install better-sqlite3.
Nuxt Image gives you <NuxtImg>, which the shop page, app/pages/shop.vue in the Code tab, uses for its oat milk photo. With width="200" it renders a plain <img> pointing at /_ipx/s_200x200/products/oat-milk.png and a 400x400 copy for sharp screens, resized on the server from the original in public/.
Nuxt Fonts reads the font names in your CSS, such as Inter in the shop's app.vue, downloads the files at build time and serves them from your site.
Plugins run your code at app start
A plugin runs code of your own when the app starts. It is a file in app/plugins/, with no config entry, and Nuxt runs it each time it creates the Vue app: on the server for every request, and once in the browser on load. The shop's pound formatter is one, a helper every component can call:
export default defineNuxtPlugin(() => {
const gbp = new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' })
return {
provide: {
money: (pence: number) => gbp.format(pence / 100),
},
}
})
money(189) divides the pence by 100, and Intl.NumberFormat, the formatter built into JavaScript, writes £1.89. Nuxt puts a $ in front of each name under provide, so money becomes $money in every template, and scripts reach it through useNuxtApp(). The shop page uses both ways:
<script setup lang="ts">
const { $money } = useNuxtApp()
const total = $money(1089)
</script>
<template>
<main>
<NuxtImg src="/products/oat-milk.png" width="200" height="200" alt="Oat milk" />
<p>Oat milk {{ $money(189) }}, total {{ total }}</p>
</main>
</template>
The page reads Oat milk £1.89, total £10.89, already in the server's HTML, because the plugin ran there first.
A file named money.client.ts would run only in the browser, and money.server.ts only on the server.
The difference from a module is when the code runs: a module runs at build time and changes what gets built, a plugin runs inside the app. Code that reads window, the browser's global object, as the app starts belongs in a client plugin: a module has finished before any browser opens a page.
Layers share a whole setup
A layer shares a whole setup: the footer, legal page and page language the bakery site needs. It is a Nuxt project that another project builds on, with its own nuxt.config.ts, app/components, app/pages and even server/. The shop keeps one in layers/base, and Nuxt registers any folder in layers/ by itself. Here is its footer:
<template>
<footer>Corner Shop, a family business since 2019</footer>
</template>
The shop's app.vue renders <ShopFooter /> with no import and no config, so every page ends with Corner Shop, a family business since 2019. The layer's legal page answers at /legal.
The bakery is a separate project, so the layer moves out of the shop, into a folder next to both sites, an npm package or a git repository. Each site lists it under extends in its nuxt.config.ts: extends: ['../shop-base'] for a folder one level up, extends: ['@corner-shop/base'] for a package. The project's own files always win, so a ShopFooter.vue of your own replaces the layer's.
What goes wrong
The first mistake: Nuxt Image, the photo module, is installed with npm install but not listed:
modules: ['@nuxt/content', '@nuxt/fonts'], // @nuxt/image installed, not listed
The shop page fails with a 500 server error, and the dev server says <NuxtImg> is provided by @nuxt/image. Check your console to install it or run 'npx nuxt module add @nuxt/image'. npm install only put the package in node_modules; a module registers its components once it is listed:
modules: ['@nuxt/content', '@nuxt/image', '@nuxt/fonts'],
The reverse mistake, listed but not installed, prints [NUXT_B8017] The module @nuxt/content could not be loaded. It may not be installed. with the install command under it.
The second mistake: a theme plugin, not in the Code tab, asks the browser through window.matchMedia() whether dark mode is on:
export default defineNuxtPlugin(() => {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
return { provide: { dark } }
})
Every page answers with a 500, and the dev server reports TypeError (500): Cannot read properties of undefined (reading 'matchMedia'): the server build defines window as undefined. The .client suffix keeps the plugin in the browser:
export default defineNuxtPlugin(() => {
const dark = window.matchMedia('(prefers-color-scheme: dark)').matches
return { provide: { dark } }
})
Pages render again, and $dark exists only in the browser: read it inside onMounted or a client-only component, both shown in the lesson SSR and hydration.
What to remember
- A module, such as Nuxt Content for Markdown pages, runs at build time and must be listed under
modules;npx nuxt module add <name>installs it and writes the line. - A plugin in
app/plugins/runs at app start, on the server and in the browser.providemakes a helper such as$moneyavailable everywhere. - A layer is a Nuxt project you build on: one in
layers/registers itself,extendspulls one in from elsewhere, and your own files win. - Code that needs
windowgoes in a.client.tsplugin, which stays off the server.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
npx nuxt module add content, addcontent.config.ts,content/journal/hello-nuxt.mdand the[slug].vuepage from the Code tab, and setcontent.experimental.sqliteConnectorto'native'innuxt.config.ts. Open/journal/hello-nuxt: the Markdown renders as HTML, and the dev log saysProcessed 2 collections and 1 files. The DevTools Modules tab lists@nuxt/contentwith its version. - Add
app/plugins/money.tsand use$money(189)in a page: View Source shows £1.89 in the server HTML. Rename the file tomoney.client.tsand reload: the page answers 500 withTypeError (500): $money is not a function, because the plugin no longer runs on the server. Rename it back. - Create
layers/base/app/components/ShopFooter.vueand use<ShopFooter />inapp.vue: the footer renders with no import and no config. Addapp/components/ShopFooter.vuewith different text: the project's version wins.