Identity

Preferences

Stores each user's settings, such as colour mode or table density, with types, validation and an optional hand-off to other apps.

Preferences stores small per-user settings, such as colour mode, table density or a notification digest. Each setting follows the user across devices. With OAuth Server, other apps that sign in through yours can receive it too, when you allow it. Install it when users should be able to choose how your app behaves for them. For settings that belong to the whole app, use module options or app.config.ts instead.

The package is @frigear-nu/preferences. Your app and any module declare each preference once, with a zod schema. That schema validates the API and generates the TypeScript types.

What you get

  • Composables and helpers (on by default): useUserPreferences() reads and sets preferences in the app, and getUserPreference reads one on the server.
  • Server routes (on by default): PATCH /api/preferences for the signed-in user.
  • Types (on by default): UserPreferences is generated from the registered files, so a misspelled key or a wrong value does not compile.
  • OAuth claims (opt-in per preference): with OAuth Server, a preference can be passed to OAuth applications as a claim. A preference is private unless its definition says so.
ServerClientMobile
Installed for you:@frigear-nu/module-kit, @frigear-nu/auth and @frigear-nu/users.You add:@nuxthub/core.Optional:@frigear-nu/oauth-server, to pass preferences on as claims.

Quick setup

Install Preferences

pnpm add @frigear-nu/preferences @nuxthub/core

Add it to nuxt.config.ts

nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxthub/core', '@frigear-nu/users', '@frigear-nu/preferences'],
})

Generate the migration

Without a migration, every write to the users table fails: the database lacks the preferences column.

The module adds a preferences column to users. Generate a migration and commit it:

pnpm db:generate

Module authors do the same in every test fixture that installs it. See Database extensions.

Declare your preferences

Declare them in shared/preferences.ts, which the module picks up automatically:

shared/preferences.ts
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),
})
What you should see: signed in, await useUserPreferences().set('colorMode', 'dark') succeeds, and get('colorMode') returns 'dark'. A value outside the schema is rejected with 400.

Options

Options go under the preferences key.

OptionTypeDefaultPurpose
definitionsstring | string[]shared/preferences.ts when it existsFiles that default-export definePreferences({...}).

definitions. Paths resolve from the project root. Listing files turns off the shared/preferences.ts default.

Server

getUserPreference and getPreferenceRegistry() are auto-imported.

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

Preferences are registered, not free-form. Unknown keys and invalid values are rejected. A stored value that no longer satisfies its schema reads as unset.

A module registers its own definition file from setup(), and 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, a preference that declares a claim is added to the ID token and to userinfo. Only applications granted the definition's scope receive it.

  • Access tokens stay bare.
  • A preference that is not set adds no claim.
  • An existing claim such as name is never replaced.
  • OAuth Server knows nothing about preferences. This module registers an oauth:claims listener.

App

Read and set the signed-in user's preferences:

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. It is also exported from @frigear-nu/preferences/composables.

Hooks

The module has one build-time hook for module authors, and listens to one hook of OAuth Server.

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

When OAuth Server is installed, the module listens to its Nitro hook oauth:claims.

Routes

Routes the module registers, and who may call them. The path is preferencesApi.root from @frigear-nu/preferences/api. @frigear-nu/preferences/utils exports it too.

MethodPathAuthStatusPurpose
PATCH/api/preferencessession only200A partial update, for example { "colorMode": "dark" }. null clears a key.
  • The body is validated against a zod schema built from the registered definitions.
  • The answer is the stored preferences (PreferencesDto), not the user row.
  • The route refreshes the session.
StatusWhen
400The body is not an object, or has an unknown key or an invalid value. Nothing is stored.
401No session.
403An API token or an access token.
404 not_foundThe user no longer exists.

Database

Tables the module adds. Generate and commit a migration after installing.

The module adds one nullable JSON column to users, through a database extension: preferences (Record<string, unknown>).

  • Every preference lives in this one column, so adding a preference never needs a migration. Only installing the module does.
  • SQLite and PostgreSQL can filter and index JSON keys when a preference must be queried.

Next steps

  • Users – the user record your preferences are stored on.
  • OAuth Server hooks – add more claims to the ID token in oauth:claims.
Copyright © 2026