Skip to content

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

  1. Each flag is declared under flags.declarations with a type and a default. The default is what the app gets when nothing else answers.
  2. Providers answer in the order of flags.providers. The kit ships selfhosted, 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.
  3. On server-rendered pages, server/middleware/flags.ts(Licensees only) resolves every declared flag once and puts the values in the payload. useFlag reads them from there; nothing is evaluated in the browser.
  4. 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:

  1. rules, top to bottom. The first rule whose every when condition matches returns its value. An array matches any of its entries, a string matches exactly.
  2. rollout. A subject whose bucket, 0 to 99, is below percent gets the rollout's value.
  3. value.
  4. 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

KeyWhereDefaultWhat it does
flags.enabledstarter.config.tsfalseOff, flag() and useFlag() return the declared defaults.
flags.providersstarter.config.ts['selfhosted']Providers to bundle, asked in this order.
flags.driverstarter.config.ts'kv'Where selfhosted reads its document: 'kv' or 'r2'.
flags.keystarter.config.ts'flags.json'The KV key or R2 object key of the document.
flags.purposestarter.config.ts'avatars'The storage purpose whose bucket the r2 driver reads. Must exist.
flags.declarationsstarter.config.tsmaintenance, newPricingEvery flag, with its type and default.
flags.adaptersstarter.config.tsnoneCredential keys of a provider you added in server/flags/adapters/custom/.
CACHEwrangler.jsoncKV namespaceThe namespace the kv driver reads.
NUXT_ANALYTICS_SALTenvironmentthe session passwordSeeds 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 useFlag returns defaults until the next full load, while flag() 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. A percent that 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.