Writing a Module
A Frigear module is a Nuxt module under packages/<name>, published as
@frigear-nu/<name>. It ships its own pages, server routes, translations and database tables, and a host application
turns it on with one entry in modules. This page is the path from an empty folder to a released module; the helpers it
uses are described in Module Kit.
Scaffold the package
From the repository root:
pnpm cli module:create my-module
pnpm install
The command downloads a starter into packages/my-module and names the package @frigear-nu/my-module. It only
creates the package, so finish the job by matching the structure of a sibling such as packages/events or
packages/cookie-consent. See CLI for the options and the checklist that follows.
Folder layout
packages/my-module/
├── src/
│ ├── module.ts the Nuxt module: meta, moduleDependencies, setup()
│ ├── types.ts ModuleOptions and the other public types
│ └── runtime/
│ ├── app/ components, composables, pages (client and SSR code)
│ ├── server/ routes, plugins, utils
│ │ └── db/ schema.sqlite.ts and schema.postgresql.ts
│ ├── i18n/lang/ da.json and en.json
│ └── utils/index.ts the `/utils` export: helpers other modules call in their setup()
├── test/
│ ├── fixtures/<name>/ a tiny Nuxt app that installs the module (nuxt.config.ts, app.vue)
│ └── *.test.ts
├── playground/ an app for `pnpm dev` in this package
├── .release-it.json
└── package.json
Only the built dist/ is published (files: ["dist"], produced by the module builder). Keep everything a host needs
under src/runtime: it ends up in dist/runtime.
package.json
Workspace consumers resolve the TypeScript source directly, and publishConfig swaps in the built files when the
package is published. Add the ./utils export whenever other modules import helpers from yours, and mirror every
entry in typesVersions so TypeScript with classic module resolution finds the types too.
{
"name": "@frigear-nu/my-module",
"type": "module",
"main": "./src/module.ts",
"exports": {
".": { "types": "./src/module.ts", "import": "./src/module.ts", "default": "./src/module.ts" },
"./utils": {
"types": "./src/runtime/utils/index.ts",
"import": "./src/runtime/utils/index.ts",
"default": "./src/runtime/utils/index.ts"
}
},
"typesVersions": {
"*": { ".": ["./src/module.ts"], "utils": ["./src/runtime/utils/index.ts"] }
},
"publishConfig": {
"exports": {
".": { "types": "./dist/types.d.mts", "import": "./dist/module.mjs", "default": "./dist/module.mjs" },
"./utils": {
"types": "./dist/runtime/utils/index.d.ts",
"import": "./dist/runtime/utils/index.js",
"default": "./dist/runtime/utils/index.js"
}
},
"main": "./dist/module.mjs",
"typesVersions": {
"*": { ".": ["./dist/types.d.mts"], "utils": ["./dist/runtime/utils/index.d.ts"] }
}
},
"files": ["dist"]
}
Declare dependencies
List every module yours needs in moduleDependencies. Nuxt then installs and orders them, so the host lists only your
module.
export default defineNuxtModule<ModuleOptions>({
meta: { name: '@frigear-nu/my-module', configKey: 'myModule' },
moduleDependencies: {
'@nuxthub/core': { version: '>=0.10' },
'@frigear-nu/module-kit': { version: '>=0.0.1' },
'@frigear-nu/auth': { version: '>=0.0.1' },
'@frigear-nu/menu': { version: '>=0.0.1', optional: true },
},
// ...
})
| Kind | In moduleDependencies | In package.json |
|---|---|---|
| Required | { version: '>=0.0.1' } | dependencies with workspace:*. |
| Optional | { version: '>=0.0.1', optional: true } | An optional peerDependencies entry (with peerDependenciesMeta) and a devDependencies entry, so tests can use it. |
Nuxt toolchain (nuxt, @nuxt/kit, @nuxt/schema, @nuxt/ui, @nuxthub/core, drizzle-orm, vitest, typescript, ...) | - | catalog:, from the catalog in the root pnpm-workspace.yaml, so the whole workspace shares one copy of each (a version of its own per package multiplies the installed Nuxt copies and slows every boot). |
- Depend on
@frigear-nu/module-kitalways: the entry is what provides theuseModuleTauto-import. Add@nuxthub/coreonly when the module has database tables. - An optional module is set up first when it is installed, so
hasNuxtModule('@frigear-nu/menu')insetup()does not depend on the order of the host'smodules. Guard every use of it, and load its helpers with a dynamicimport()inside the guard, for exampleawait import('@frigear-nu/widget/utils'). moduleDependenciesmay be a function ofnuxtwhen a dependency depends on options, as@frigear-nu/usersdoes for MCP.
The setup() checklist
Most modules call the same helpers, in this order:
import { addServerHandler, addServerImportsDir, createResolver, defineNuxtModule } from '@nuxt/kit'
import {
addModulePages,
inlineModuleRuntime,
moduleApiUrl,
registerDatabaseTable,
registerLocales,
registerModule,
registerTailwindSource,
resolvePagePath,
} from '@frigear-nu/module-kit/utils'
const MODULE_NAME = '@frigear-nu/my-module'
export default defineNuxtModule<ModuleOptions>({
// meta, defaults, moduleDependencies ...
setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
// 1. Make the module known to the rest of the app.
registerModule(MODULE_NAME, nuxt)
// 2. Classes used in the module's components must be generated by Tailwind.
registerTailwindSource(nuxt, resolver.resolve('./runtime/app'))
// 3. Translations: da.json and en.json, namespaced by module.
registerLocales(nuxt, resolver.resolve('./runtime/i18n/lang'))
// 4. Pages are opt-in or overridable through settings.pages.*.
addModulePages(MODULE_NAME, resolver, [
{
name: 'my-module',
path: resolvePagePath(options.settings?.pages?.index, '/my-module'),
file: './runtime/app/pages/index.vue',
},
])
// 5. Server routes, auto-imports and bundling of the runtime.
addServerImportsDir(resolver.resolve('./runtime/server/utils'))
inlineModuleRuntime(nuxt, resolver)
addServerHandler({
route: moduleApiUrl('my-module', 'things'),
method: 'get',
handler: resolver.resolve('./runtime/server/routes/things.get'),
})
// 6. Tables: a plain drizzle file per dialect. No `name`, nothing to await.
registerDatabaseTable(nuxt, {
resolver,
schema: dialect => `./runtime/server/db/schema.${dialect}`,
})
},
})
Notes on the choices above:
registerModuletakes the full package name;moduleUrlandmoduleApiUrltake the short name ('my-module'), which they kebab-case into the path.- Call
inlineModuleRuntimenext toaddServerImportsDir. Without it the server auto-imports and virtual modules of an installed module do not resolve in the host app. - Authenticate server routes with
requireAuthfrom@frigear-nu/authrather than the session helpers (see the repositoryAGENTS.md), and declare@frigear-nu/authinmoduleDependencieswhen you do. - Menus, widgets and admin pages belong to optional modules: use
addOptionalMenuSourceand the guarded imports described in Module Kit.
Add a database table
Write the schema twice, once per dialect, as plain drizzle tables:
// src/runtime/server/db/schema.sqlite.ts
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'
export const things = sqliteTable('things', {
id: integer().primaryKey({ autoIncrement: true }),
name: text().notNull(),
})
// src/runtime/server/db/schema.postgresql.ts
import { pgTable, text, serial } from 'drizzle-orm/pg-core'
export const things = pgTable('things', {
id: serial().primaryKey(),
name: text().notNull(),
})
Register the file as in the checklist above. Every table export is picked up. The files are parsed as text, so keep them to imports and table calls, as described under Constraints of schema files.
Add a parity test, so the dialects cannot drift:
import { it } from 'vitest'
import { expectSchemaParity } from '@frigear-nu/module-kit/testing'
import * as sqlite from '../src/runtime/server/db/schema.sqlite'
import * as postgresql from '../src/runtime/server/db/schema.postgresql'
it('schema.postgresql matches schema.sqlite', () => expectSchemaParity(sqlite, postgresql))
Extend another module's table
To add columns to a table that another module owns, for example users, ship the columns as an exported table of the
same name and register the file. The columns arrive in every app that installs both modules, and nothing happens when
the owner is absent:
// in setup()
extendDatabaseTable(nuxt, {
resolver,
file: dialect => `./runtime/server/db/users.${dialect}`,
})
// src/runtime/server/db/users.sqlite.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core'
export const users = sqliteTable('users', {
nickname: text(),
})
Ship a users.postgresql.ts too. Because extendDatabaseTable only changes the generated schema, document that host
apps must generate and commit a migration; your own fixtures need one as well (next section).
Test the module
Tests run with Vitest and @nuxt/test-utils. A fixture is a minimal Nuxt app that installs the module from source:
// test/fixtures/basic/nuxt.config.ts
import MyModule from '../../../src/module'
export default defineNuxtConfig({
modules: ['@nuxthub/core', MyModule],
runtimeConfig: { session: { password: 'a-test-only-session-password-at-least-32-chars' } },
hub: { db: { dialect: 'sqlite', driver: 'libsql', applyMigrationsDuringBuild: false } },
})
// test/basic.test.ts
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { $fetch, setup } from '@nuxt/test-utils/e2e'
describe('my-module', async () => {
await setup({ rootDir: fileURLToPath(new URL('./fixtures/basic', import.meta.url)) })
it('renders', async () => {
expect(await $fetch('/')).toContain('basic')
})
})
Run from the repository root, never from inside the package:
pnpm --filter @frigear-nu/my-module lint
pnpm --filter @frigear-nu/my-module test
Fixtures with a database
A fixture whose tests touch the database needs committed migrations for the merged schema: your tables plus the
columns every extending module adds, and the auth_user_tokens table when the fixture includes a module that uses
requireAuth. The recipe:
- Add the modules to the fixture's
nuxt.config.ts(test-only session password, local libsql database). - Generate the migrations, review them and commit the SQL together with the snapshot and journal:
pnpm --filter @frigear-nu/my-module exec nuxt db generate --cwd "$PWD/packages/my-module/test/fixtures/<fixture>" - Run the suite with
dev: trueinsetup()so NuxtHub applies the migrations on startup, and delete the fixture's.datadirectory before and after. - After any schema change, run the same command again.
No schema changes, nothing to migratemeans the fixture is in sync.
The command, its --cwd rule, how to name a migration and the fixture requirements are in
Module Kit. Working examples are the
self-service, admin-db and magic-link-db fixtures of packages/users and auth-db of packages/auth.
Document and release
- Add a page under
apps/docs/content/4.modules/using the module page layout from the docs README, and a row in Modules and the rootREADME.md. - Copy a sibling's
.release-it.json, change the package name and tag, and add the package to therelease.ymlworkflow. Publishing packages covers versioning, the workflow and resuming an interrupted publish. - Before opening a pull request run
pnpm lint,pnpm typecheckandpnpm testfrom the repository root, andpnpm buildfor anything that touchesexportsor build tooling.