Modules

Preferences

Typed per-user preferences that modules and the host app register, stored on the user and optionally forwarded to OAuth applications as claims.

Per-user preferences (color mode, table density, notification digests, ...) as a Nuxt module. Each preference is declared once, with a zod schema, by the host app or any module. The schemas are the single source of truth: they validate the API and generate the TypeScript types.

Requires@nuxthub/core, @frigear-nu/module-kit, @frigear-nu/auth and @frigear-nu/users; all are installed automatically. Optional:@frigear-nu/oauth-server, to forward preferences as claims.

Quick setup

export default defineNuxtConfig({
  modules: ['@frigear-nu/users', '@frigear-nu/preferences'],
})
The module adds a preferences column to users. That only changes the generated Drizzle schema, so generate and commit a migration in your app and in every fixture that uses the module. See Module Kit.

Declare preferences in shared/preferences.ts (picked up automatically):

import { z } from 'zod'
import { definePreferences } from '@frigear-nu/preferences/define'

export default definePreferences({
  // Plain schema: stored and readable by this app only.
  compactTables: z.boolean(),

  // Also forwarded to OAuth applications as the `color_mode` claim.
  colorMode: { schema: z.enum(['light', 'dark', 'system']), claim: 'color_mode' },

  // Defaults come from the schema.
  pageSize: z.number().int().min(10).max(100).default(25),
})

Options

OptionTypeDefaultPurpose
definitionsstring | string[]shared/preferences.ts when it existsFiles default-exporting definePreferences({...}), resolved from the project root. Listing files disables the shared/preferences.ts default.

Design:

  • One JSON column. Every preference lives in users.preferences, so adding a preference never needs a migration; only installing the module does. SQLite and Postgres can filter and index JSON keys when a preference must be queried.
  • Registered, not free-form. Unknown keys and invalid values are rejected, and a stored value that no longer satisfies its schema reads as unset.
  • Typed. UserPreferences is generated from the registered files, so a misspelled key or wrong value does not compile.
  • Private by default. A preference is forwarded to OAuth applications only when its definition says so.

Server

const user = await requireUser(event)
getUserPreference(user, 'colorMode') // typed; undefined when unset or no longer valid

getUserPreference and getPreferenceRegistry() are auto-imported.

A module registers its own definer file from setup(); its keys join UserPreferences the same way. The same key in two files fails at startup.

import { addUserPreferences } from '@frigear-nu/preferences/utils'

addUserPreferences(nuxt, { path: resolver.resolve('./runtime/preferences') }) // absolute path

A definition is a zod schema, or an object with a schema and how it is exposed:

type PreferenceDefinition<S extends z.ZodType = z.ZodType>
  = S | {
    schema: S
    /** Forward the value to OAuth applications under this claim name (ID token and `userinfo`). */
    claim?: string
    /** Scope an application must have been granted to receive the claim. Defaults to `profile`. */
    scope?: string
  }

OAuth claims

With OAuth Server, preferences that declare a claim are added to the ID token and userinfo for applications granted the definition's scope. Access tokens stay bare, a preference that is not set adds no claim, and an existing claim such as name is never replaced. The OAuth server knows nothing about preferences: this module registers an oauth:claims listener.

App

const { preferences, get, set, pending } = useUserPreferences()

preferences.value.colorMode // 'light' | 'dark' | 'system' | undefined
get('colorMode') // the same, for one key
await set('colorMode', 'dark') // validated by the server; `null` clears it, then the session is refreshed

useUserPreferences is auto-imported and also exported from @frigear-nu/preferences/composables.

Hooks

HookWhenPayload
preferences:definitions (Nuxt)Build time, when the definer files are collected{ path: string }[] (push to register; addUserPreferences does this)

The module listens to the Nitro hook oauth:claims of OAuth Server when that module is installed.

Routes

MethodPathAuthPurpose
PATCH/api/preferencessession onlyPartial update, e.g. { "colorMode": "dark" }; null clears a key. Answers with the resulting preferences and refreshes the session.

Status codes: 400 for a body that is not an object, an unknown key or an invalid value; 401 without a session; 403 for API tokens and OAuth access tokens; 404 when the user no longer exists.

Database

The module extends users through extendDatabaseTable with one nullable JSON column, preferences (Record<string, unknown>).

Copyright © 2026