Getting started

Writing a Module

Scaffold a new Frigear Nuxt module, wire it into the workspace with module-kit, give it a database table, test it and release it.

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 },
  },
  // ...
})
KindIn moduleDependenciesIn 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-kit always: the entry is what provides the useModuleT auto-import. Add @nuxthub/core only when the module has database tables.
  • An optional module is set up first when it is installed, so hasNuxtModule('@frigear-nu/menu') in setup() does not depend on the order of the host's modules. Guard every use of it, and load its helpers with a dynamic import() inside the guard, for example await import('@frigear-nu/widget/utils').
  • moduleDependencies may be a function of nuxt when a dependency depends on options, as @frigear-nu/users does 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:

  • registerModule takes the full package name; moduleUrl and moduleApiUrl take the short name ('my-module'), which they kebab-case into the path.
  • Call inlineModuleRuntime next to addServerImportsDir. Without it the server auto-imports and virtual modules of an installed module do not resolve in the host app.
  • Authenticate server routes with requireAuth from @frigear-nu/auth rather than the session helpers (see the repository AGENTS.md), and declare @frigear-nu/auth in moduleDependencies when you do.
  • Menus, widgets and admin pages belong to optional modules: use addOptionalMenuSource and 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:

  1. Add the modules to the fixture's nuxt.config.ts (test-only session password, local libsql database).
  2. 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>"
    
  3. Run the suite with dev: true in setup() so NuxtHub applies the migrations on startup, and delete the fixture's .data directory before and after.
  4. After any schema change, run the same command again. No schema changes, nothing to migrate means 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 root README.md.
  • Copy a sibling's .release-it.json, change the package name and tag, and add the package to the release.yml workflow. Publishing packages covers versioning, the workflow and resuming an interrupted publish.
  • Before opening a pull request run pnpm lint, pnpm typecheck and pnpm test from the repository root, and pnpm build for anything that touches exports or build tooling.
Copyright © 2026