Preferences
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.
@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'],
})
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
| Option | Type | Default | Purpose |
|---|---|---|---|
definitions | string | string[] | shared/preferences.ts when it exists | Files 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.
UserPreferencesis 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
| Hook | When | Payload |
|---|---|---|
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
| Method | Path | Auth | Purpose |
|---|---|---|---|
PATCH | /api/preferences | session only | Partial 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>).