Building blocks
Feature flags
Flags are typed values you declare in starter.config.ts(Licensees only)
and read with flag() on the server or useFlag() in a component. Use one as a
kill switch, or to show a feature to staff or a share of visitors first. Flags
ship off.
How it works
- Each flag is declared under
flags.declarationswith a type and a default. The default is what the app gets when nothing else answers. - Providers answer in the order of
flags.providers. The kit shipsselfhosted, which evaluates a JSON document stored in KV or R2. A provider that doesn't know a flag passes it to the next one, and the declared default comes last. - On server-rendered pages,
server/middleware/flags.ts(Licensees only) resolves every declared flag once and puts the values in the payload.useFlagreads them from there; nothing is evaluated in the browser. flag(event, key)evaluates one flag on demand, in API routes, jobs and anywhere else you have an event.
Turn flags on
Set flags.enabled: true:
flags: {
enabled: true,
providers: ['selfhosted'],
driver: 'kv',
key: 'flags.json',
purpose: 'avatars',
declarations: {
maintenance: { type: 'boolean', default: false },
newPricing: { type: 'boolean', default: false },
},
},
The kv driver reads the key flags.json from the CACHE namespace that
Nitro's cache already binds, so there is nothing new to create. Until you write
a document, every flag reads its default.
Declare a flag
Give it a type of boolean, string or number and a default of that type:
declarations: {
maintenance: { type: 'boolean', default: false },
checkoutCopy: { type: 'string', default: 'control' },
freeSeats: { type: 'number', default: 3 },
},
The return type comes from the declaration, so flag(event, 'freeSeats') is a
Promise<number> and an undeclared key fails type checking. Ids must match
/^[a-z][a-zA-Z0-9]*$/: newPricing works, new-pricing fails the build. The
build also refuses a default of the wrong type, and flags turned on with none
declared.
Read a flag
On the server, import flag from #server/flags:
import { flag } from '#server/flags'
export default defineEventHandler(async (event) => {
if (await flag(event, 'maintenance')) {
throw createError({ statusCode: 503, statusMessage: 'Back in a minute.' })
}
return { ok: true }
})
flag never throws and always resolves to a value. A provider that throws or
answers with the wrong type is skipped. A document that fails to read or parse
is logged as [flags] reading the document failed and treated as absent.
In a component, useFlag returns a computed ref:
<script setup lang="ts">
const newPricing = useFlag('newPricing')
</script>
<template>
<UBadge v-if="newPricing" label="New" />
</template>
Read a flag on a client-rendered page
The middleware skips /api/ paths, non-GET requests, and routes whose rule sets
cache or ssr: false. On those pages the payload has no flags and useFlag
returns the declared default. As shipped that covers /blog, /legal, the
dashboard, the organization pages under /o and /invite.
There, fetch the values from a route of your own, such as
server/api/flags.get.ts:
import { flag } from '#server/flags'
export default defineEventHandler(async (event) => ({
newPricing: await flag(event, 'newPricing'),
}))
const { data: flags } = await useFetch('/api/flags')
Write the flag document
The selfhosted provider reads one JSON document:
{
"flags": {
"maintenance": { "value": false },
"newPricing": {
"value": false,
"rules": [{ "when": { "role": ["staff", "superadmin"] }, "value": true }],
"rollout": { "percent": 10, "value": true }
}
}
}
For each flag it checks, in order:
rules, top to bottom. The first rule whose everywhencondition matches returns itsvalue. An array matches any of its entries, a string matches exactly.rollout. A subject whose bucket, 0 to 99, is belowpercentgets the rollout'svalue.value.- Nothing: the next provider answers, then the declared default.
A condition can name locale (the locale code, such as sr) for any visitor,
and role or userId for a signed-in one. role takes the ids in
access.rolePermissions (user, staff, superadmin), and userId is the
account id as a string. A condition on something the subject doesn't have never
matches.
The subject is user:<id> when signed in. Anyone else is visitor:<id>, the
cookieless visitor id analytics uses (Analytics). The bucket
hashes the flag id with the subject, so each flag splits visitors differently,
and a signed-in user keeps their bucket across deploys.
Upload the document to KV with Wrangler. --local writes to the simulator
pnpm dev reads, --remote to the deployed namespace:
pnpm exec wrangler kv key put flags.json --path flags.json --binding CACHE --remote
A KV write can take up to a minute to reach every Cloudflare location. The document is read once per request.
To keep it in R2 instead, set driver: 'r2'. The driver reads flags.key from
the bucket of the storage purpose that flags.purpose names, AVATARS as
shipped (Storage):
pnpm exec wrangler r2 object put nuxter-avatars/flags.json --file flags.json --remote
Add a provider
A hosted flag service plugs in as one file. Create
server/flags/adapters/custom/<id>.ts, default-export a FlagAdapter, and add
the id to flags.providers. The file name is the id, so this is
server/flags/adapters/custom/remote.ts:
import type { FlagAdapter } from '#server/flags/types'
const remote: FlagAdapter = {
id: 'remote',
async evaluate(key, _fallback, subject, { credentials }) {
try {
const answer = await $fetch<{ value?: boolean | string | number }>(
`https://flags.example.com/v1/flags/${key}`,
{
query: { subject: subject.key, ...subject.attributes },
headers: { authorization: `Bearer ${String(credentials.apiKey)}` },
},
)
return answer.value
} catch {
return undefined
}
},
}
export default remote
flags: {
enabled: true,
providers: ['remote', 'selfhosted'],
adapters: { remote: { credentials: ['apiKey'] } },
},
Each key in credentials becomes one variable, here
NUXT_FLAGS_REMOTE_API_KEY, and reaches the adapter as
context.credentials.apiKey. Without a credentials list, the whole slot is
one JSON variable, NUXT_FLAGS_REMOTE. Answer undefined for a flag the
service doesn't know, and don't throw. Ids must be one lowercase word. Restart
pnpm dev after adding the file so the build registers the id.
Configuration
| Key | Where | Default | What it does |
|---|---|---|---|
flags.enabled | starter.config.ts | false | Off, flag() and useFlag() return the declared defaults. |
flags.providers | starter.config.ts | ['selfhosted'] | Providers to bundle, asked in this order. |
flags.driver | starter.config.ts | 'kv' | Where selfhosted reads its document: 'kv' or 'r2'. |
flags.key | starter.config.ts | 'flags.json' | The KV key or R2 object key of the document. |
flags.purpose | starter.config.ts | 'avatars' | The storage purpose whose bucket the r2 driver reads. Must exist. |
flags.declarations | starter.config.ts | maintenance, newPricing | Every flag, with its type and default. |
flags.adapters | starter.config.ts | none | Credential keys of a provider you added in server/flags/adapters/custom/. |
CACHE | wrangler.jsonc | KV namespace | The namespace the kv driver reads. |
NUXT_ANALYTICS_SALT | environment | the session password | Seeds the anonymous visitor id that rollouts bucket on. |
Good to know
- Values are fixed for a full page load. Sign-in finishes without a reload, so
after it
useFlagreturns defaults until the next full load, whileflag()on the server already sees the user. Sign-out and impersonation reload the page. - Anonymous visitors get a new id every UTC day and land in new buckets. A
rollout that has to hold across days belongs behind sign-in. Requests with no
IP and no user agent all share the subject
visitor:anonymous, one bucket. - A document value counts only when its type matches the declaration:
"true"on a boolean flag is ignored. Apercentthat isn't a number counts as 0. - The middleware evaluates every declared flag on every server-rendered page. A provider that calls a remote API makes one call per flag per page.
- Flag calls can stay in your code with
flags.enabled: false. They return the defaults.
A link with a lock opens the kit’s private repository, which every licensee can read.