Guide

flags

Feature flags let you merge unfinished work behind a switch, turn a redesign on for 10% of users, or switch a feature off in production without a deploy. @kitr/flags declares every flag once, with a typed default, so a misspelt flag is a type error.

Providers decide each value: an env variable, a stable percentage rollout, your own rules, or any OpenFeature vendor such as LaunchDarkly or Unleash. The server resolves per request and embeds a snapshot so the browser starts with the same values.

Flags are not permissions: use auth abilities for those. For a value that is the same for everyone in an environment, a config file is simpler.

The examples follow Acme's dashboard at app.acme.com, which is rolling out a redesign (new-dashboard) and raising the invoice upload limit (max-upload-mb).

Install

kitr add flags

First time? Run npm i -g @kitr/kitr once. Inside a project, the global kitr runs your project's own version.

Everything kitr add changes

What kitr add does:

  • Adds @kitr/flags to dependencies in package.json and installs it. Pass --no-install to skip the install.
  • Adds () => import('@kitr/flags/provider') to providers and '@kitr/flags' to packages in kitrrc.ts. The provider binds flags in the container.
  • Writes app/flags.ts, which declares two example flags, and config/flags.ts, which lists the providers (see Configure).
  • Adds a ## kitr: flags section to AGENTS.md. In a project with React it says: flags live in app/flags.ts, components read useFlag('new-dashboard') from @kitr/flags/react, the server resolves them per request with resolveFlags and embeds them with <FlagsScript />, and feature switches never read import.meta.env. In a server-only project it says: resolve per request with resolveFlags(app, event, { userId }) from @kitr/flags/server, providers ordered in config/flags.ts.

It adds no env variables or scripts and asks no questions. No kit includes flags.

kitr remove flags takes the provider and the package out of kitrrc.ts, deletes config/flags.ts, and removes the dependency and the AGENTS.md section. It keeps app/flags.ts, because your code imports from it.

Configure

kitr add flags writes two files. The flags themselves:

// app/flags.ts
import { defineFlags } from '@kitr/flags'

/** Every flag with its default, so unknown flags are a type error. */
export const flags = defineFlags({
  'new-dashboard': { default: false, description: 'The redesigned dashboard' },
  'max-upload-mb': { default: 10 },
})

And where their values come from:

// config/flags.ts
import { defineConfig, envFlagsProvider, percentageFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

export default defineConfig({
  definitions: flags,
  // First provider with an answer wins: FLAG_NEW_DASHBOARD=true in .env, then a 10% rollout.
  providers: [envFlagsProvider(), percentageFlagsProvider({ 'new-dashboard': 10 })],
  // openFeatureFlagsProvider(client) plugs in LaunchDarkly, Flagsmith, Unleash... through OpenFeature.
})
Option Default Description
definitions {} The flags from app/flags.ts. A flag not listed here has no value.
providers [] Asked in order for each flag; the first with an answer wins. With none, every flag is its default.
ttl none Seconds before the browser checks flags again, so changes reach open tabs. Unset checks once per page load.

The first provider that answers with a value of the right type wins; otherwise the flag's default answers. A provider that throws is skipped with a console.warn, so an outage falls back to defaults.

envFlagsProvider() reads FLAG_ plus the flag key in upper case, with dashes, dots and camelCase humps turned into underscores:

Variable Used for
FLAG_NEW_DASHBOARD new-dashboard: true, 1, on or yes turn it on; false, 0, off, no or empty turn it off.
FLAG_MAX_UPLOAD_MB max-upload-mb: any finite number, such as 25.

The env provider answers on the server only; the browser relies on the server's snapshot (see Send the server's answer to the browser).

Show a feature to some users

In a component, useFlag from @kitr/flags/react returns a flag's current value and re-renders when it changes:

// app/components/dashboard/dashboard-page.tsx
import { useFlag } from '@kitr/flags/react'
import type { flags } from '#app/flags'
import { ClassicDashboard } from '#app/components/dashboard/classic-dashboard'
import { NewDashboard } from '#app/components/dashboard/new-dashboard'

export function DashboardPage() {
  const redesigned = useFlag<typeof flags, 'new-dashboard'>('new-dashboard')
  return redesigned ? <NewDashboard /> : <ClassicDashboard />
}

With FLAG_NEW_DASHBOARD=true in .env and a server restart, everyone sees the redesign. Without it, 10% of signed-in users, chosen by user id, still do; everyone else sees the classic one.

Declare flags

Every flag lives in app/flags.ts. The default sets both the value when no provider answers and the flag's type:

// app/flags.ts
import { defineFlags } from '@kitr/flags'

export const flags = defineFlags({
  'new-dashboard': { default: false, description: 'The redesigned dashboard' },
  'max-upload-mb': { default: 10, description: 'Largest invoice PDF a team may upload' },
  'invoice-layout': { default: 'classic', description: "'classic' or 'compact' invoice PDFs" },
})
Default Flag type Typical use
false boolean Show or hide a feature.
10 number A limit or a size.
'classic' string A variant name for an experiment.

defineFlags keeps exact types, so useFlag<typeof flags, 'max-upload-mb'> is a number and an undeclared key is a type error. Write description for whoever flips the flag in six months.

Type the hooks once

Wrap useFlag once instead of repeating typeof flags in every component:

// app/use_flag.ts
import { useFlag } from '@kitr/flags/react'
import type { flags } from '#app/flags'

type AcmeFlags = typeof flags

/** `useAcmeFlag('max-upload-mb')` is a number; an unknown key is a type error. */
export function useAcmeFlag<K extends keyof AcmeFlags & string>(key: K) {
  return useFlag<AcmeFlags, K>(key)
}
// app/components/invoices/upload-hint.tsx
import { useAcmeFlag } from '#app/use_flag'

export function UploadHint() {
  const maxMb = useAcmeFlag('max-upload-mb')
  return <p>PDFs up to {maxMb} MB.</p>
}
// => <p>PDFs up to 10 MB.</p>   (no provider answered, so the default)

Pick flags per platform

A project with a phone app and a desktop app keeps one flags service and splits definitions into groups: mobileFlags in app/flags/mobile.ts, desktopFlags in app/flags/desktop.ts, and their union in app/flags.ts. config/flags.ts lists the union. A platform's config folder replaces it for that build; see Platforms.

Phone build config

config/phone/flags.ts lists only mobileFlags and can use its own providers:

// config/phone/flags.ts
import { defineConfig, envFlagsProvider } from '@kitr/flags'
import { mobileFlags } from '#app/flags/mobile'

export default defineConfig({ definitions: mobileFlags, providers: [envFlagsProvider()] })

Read every flag at once

useFlags() from @kitr/flags/react returns every flag as one object, for a settings page or a debug panel:

// app/components/settings/flags-panel.tsx
import { useFlags } from '@kitr/flags/react'
import type { flags } from '#app/flags'

export function FlagsPanel() {
  const values = useFlags<typeof flags>()
  return <pre>{JSON.stringify(values, null, 2)}</pre>
}
// => { "new-dashboard": false, "max-upload-mb": 10, "invoice-layout": "classic" }

The object is frozen and stable until the flags change, so memoised children can take it.

Read a flag outside React

Outside components, useFlags(app) from @kitr/flags returns the service. Reads are synchronous and answer from the current snapshot:

// app/lib/invoices/validate-upload.ts
import type { KitrApp } from '@kitr/kitr/app'
import { useFlags } from '@kitr/flags'
import type { flags } from '#app/flags'

export function checkUploadSize(app: KitrApp, file: File) {
  const maxMb = useFlags<typeof flags>(app).value('max-upload-mb')
  if (file.size > maxMb * 1024 * 1024) return `Invoices must be under ${maxMb} MB.`
  return null
}
Method Returns Description
value(key) the flag's type The current value: the snapshot, or the default before one exists.
isEnabled(key) boolean true only when the value is exactly true.
snapshot() all values One frozen object with every flag.
resolve(context?) Promise of all values Ask the providers for context. See below.
hydrate(values) void Load values the server resolved.
subscribe(listener) unsubscribe function Called after each resolve or hydrate that updates the snapshot.
resolved boolean Whether the snapshot came from a resolve or hydrate rather than defaults.
definitions the definitions What app/flags.ts declared.

Read flags on the server

The server answers many users at once, so flags resolve per request. resolveFlags(app, event, context) from @kitr/flags/server asks the providers for this request's user and caches the answer on the request:

// server/controllers/dashboard-controller.ts
import type { HttpContext } from '@kitr/api'
import { resolveFlags } from '@kitr/flags/server'
import type { flags } from '#app/flags'
import { classicSummary, newSummary } from '#server/dashboard/summaries'

export default class DashboardController {
  async show({ app, event }: HttpContext) {
    const values = await resolveFlags<typeof flags>(app, event)
    return values['new-dashboard'] ? newSummary() : classicSummary()
  }
}

The user comes from locals.user.id when auth middleware set it. Pass more context as the third argument; every provider receives it:

// server/controllers/invoices-controller.ts
import type { HttpContext } from '@kitr/api'
import { resolveFlags } from '@kitr/flags/server'
import type { flags } from '#app/flags'

export default class InvoicesController {
  async uploadLimit({ app, event, locals }: HttpContext) {
    const team = locals.team as { id: string; plan: 'free' | 'pro' }
    const values = await resolveFlags<typeof flags>(app, event, { teamId: team.id, plan: team.plan })
    return { maxMb: values['max-upload-mb'] }
    // => { maxMb: 10 }
  }
}

Inside a request, useFlags(app).value(...) answers for that request's user. The shared root app never keeps a resolved snapshot, so one user's answer can't leak to the next.

Send the server's answer to the browser

The browser can't read process.env and may not know the user id at boot. The server resolves the flags and embeds them as a JSON script tag, which the client provider loads before the first render. <FlagsScript values={...} /> from @kitr/flags/react renders it. Resolve in the root route's loader and put the tag in the document head:

// app/server/load_flags.ts
import { createServerFn } from '@kitr/actions'
import { resolveFlags } from '@kitr/flags/server'
import type { flags } from '#app/flags'
import { server } from '#server/main'
import { currentUserId } from '#server/session'

export const loadFlags = createServerFn({ method: 'GET' }).handler(async () => {
  await server.boot()
  return resolveFlags<typeof flags>(server.app, { context: {} }, { userId: await currentUserId() })
})
// app/routes.ts
import { head, RootDocument } from './document'
import { loadFlags } from '#app/server/load_flags'

r.root({ head, loader: () => loadFlags(), component: RootDocument })
// app/document.tsx
import { FlagsScript } from '@kitr/flags/react'
import { getRouteApi, HeadContent, Outlet, Scripts } from '@kitr/router'

const root = getRouteApi('__root__')

export function RootDocument() {
  const flags = root.useLoaderData()
  return (
    <html lang="en">
      <head>
        <HeadContent />
        <FlagsScript values={flags} />
      </head>
      <body>
        <Outlet />
        <Scripts />
      </body>
    </html>
  )
}

The page now carries:

<script type="application/json" id="__kitr_flags__">{"new-dashboard":true,"max-upload-mb":10}</script>

Values are escaped, so a string flag can't close the tag. The client keeps only declared keys, coerces each to its type, and ignores a broken tag.

Without React, and first-paint mismatches

flagsScript(values) from @kitr/flags/server returns the same tag as a string, for a server that writes HTML by hand:

// server/pages/invoice_print.ts
import { flagsScript } from '@kitr/flags/server'

const head = `<head><title>Invoice</title>${flagsScript({ 'invoice-layout': 'compact' })}</head>`
// => <head><title>Invoice</title><script type="application/json" id="__kitr_flags__">{"invoice-layout":"compact"}</script></head>

During server rendering, useFlag renders defaults if the app has not resolved flags for the request, so the browser's first render can differ. For UI that must match on first paint, read the value from the root loader's data on both sides.

In a single-page app there is nothing to embed. The provider calls resolve() at boot with no context, so env variables and percentage rollouts don't apply until you resolve again with a user (next section).

Resolve again after sign-in

In the browser, resolve(context) updates the snapshot and every useFlag re-renders. Call it once you know who the user is:

// app/components/auth/after-sign-in.tsx
import { useApp } from '@kitr/react'
import { useEffect } from 'react'

export function ResolveFlagsFor({ userId }: { userId: string }) {
  const app = useApp()
  useEffect(() => {
    void app.container.use('flags').resolve({ userId })
  }, [app, userId])
  return null
}

Render <ResolveFlagsFor userId={user.id} /> in the signed-in layout; the dashboard switches without a reload. Call resolve({}) on sign-out to drop back to the anonymous answer.

Providers

List providers in config/flags.ts in priority order; the first with an answer wins.

Provider Answers from Silent when
envFlagsProvider(env?, prefix?) FLAG_* env variables The variable is unset, or can't be read as the flag's type.
percentageFlagsProvider(shares, salt?) A stable share of user ids The flag is not in shares.
staticFlagsProvider(values) A map, or a function of the context The map has no entry for the flag.
openFeatureFlagsProvider(client, flags) Any OpenFeature client Never: the client returns its own default.

Env variables

Built in. Good for turning a flag on per environment:

// config/flags.ts
import { defineConfig, envFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

export default defineConfig({
  definitions: flags,
  providers: [envFlagsProvider()],
})
FLAG_NEW_DASHBOARD=true
FLAG_MAX_UPLOAD_MB=25

Pass a map to read from somewhere other than process.env, and a prefix second: envFlagsProvider(process.env, 'ACME_FLAG_'). Changing an env variable needs a server restart.

Percentage rollout

Built in. Turns a flag on for a share of users, from 0 to 100:

// config/flags.ts
import { defineConfig, envFlagsProvider, percentageFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

export default defineConfig({
  definitions: flags,
  providers: [envFlagsProvider(), percentageFlagsProvider({ 'new-dashboard': 25 }, 'dashboard-2026')],
})

The answer is a hash of the salt, flag key and user id, so a user gets the same answer on every request and server. Raising 25 to 50 keeps everyone who had the feature. Change the salt to draw a new sample.

Static rules

Built in. A fixed map, or a function of the context for rules you write in code:

// config/flags.ts
import { defineConfig, envFlagsProvider, staticFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

export default defineConfig({
  definitions: flags,
  providers: [
    envFlagsProvider(),
    staticFlagsProvider((context) => ({
      'new-dashboard': context.teamId === 'design',
      'max-upload-mb': context.plan === 'pro' ? 100 : undefined,
    })),
  ],
})

The design team sees the new dashboard. Pro teams get 100 MB; undefined means "no opinion", so everyone else gets the default. The context holds userId plus whatever you passed to resolveFlags or resolve.

OpenFeature

Connect any vendor with an OpenFeature provider. Install the SDK for the side you resolve on, plus your vendor's provider:

pnpm add @openfeature/server-sdk
// config/flags.ts
import { InMemoryProvider, OpenFeature } from '@openfeature/server-sdk'
import { defineConfig, envFlagsProvider, openFeatureFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

// Replace InMemoryProvider with your vendor's provider, configured with its SDK key.
OpenFeature.setProvider(
  new InMemoryProvider({
    'new-dashboard': { variants: { on: true, off: false }, defaultVariant: 'on', disabled: false },
  }),
)

export default defineConfig({
  definitions: flags,
  providers: [envFlagsProvider(), openFeatureFlagsProvider(OpenFeature.getClient(), flags)],
})

The adapter calls the getter that matches each flag's type, with targetingKey set to userId. Keep the vendor's key in .env as its docs say.

Values a provider can return

Each answer is coerced to the flag's declared type. One that can't be read counts as no answer, and the next provider is asked:

Declared type Accepted answers Ignored answers
boolean true/false; 'true', '1', 'on', 'yes'; 'false', '0', 'off', 'no', ''; numbers (0 is false) 'maybe'
number Numbers; strings that parse to a finite number, such as ' 7.5 ' 'lots', '', '12px', 'Infinity', booleans
string Strings; numbers and booleans, turned into strings None
// scripts/coercion_demo.ts
import { createFlags, envFlagsProvider, staticFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'

const demo = createFlags(flags, {
  providers: [
    envFlagsProvider({ FLAG_NEW_DASHBOARD: 'yes', FLAG_MAX_UPLOAD_MB: 'lots' }),
    staticFlagsProvider({ 'max-upload-mb': 50 }),
  ],
})
await demo.resolve()
// => { 'new-dashboard': true, 'max-upload-mb': 50, 'invoice-layout': 'classic' }

'lots' is not a number, so the static provider's 50 wins.

Retire a flag

Once a feature has shipped to everyone, delete its flag from app/flags.ts:

// app/flags.ts
import { defineFlags } from '@kitr/flags'

export const flags = defineFlags({
  'max-upload-mb': { default: 10, description: 'Largest invoice PDF a team may upload' },
  'invoice-layout': { default: 'classic', description: "'classic' or 'compact' invoice PDFs" },
})

Every typed read of it is now a type error, which lists the code to clean up. Then remove FLAG_NEW_DASHBOARD and the 'new-dashboard' share in config/flags.ts.

Testing

createFlags(definitions, config) builds a flags service with no app. Give it a staticFlagsProvider to test both sides of a flag:

// app/lib/invoices/validate-upload.test.ts
import { createFlags, staticFlagsProvider } from '@kitr/flags'
import { describe, expect, it } from 'vitest'
import { flags } from '#app/flags'

describe('upload limit flag', () => {
  it('answers the default until resolved, then the provider', async () => {
    const service = createFlags(flags, { providers: [staticFlagsProvider({ 'max-upload-mb': 25 })] })
    expect(service.value('max-upload-mb')).toBe(10)
    expect(service.resolved).toBe(false)

    expect(await service.resolve({ userId: 'ada' })).toEqual({
      'new-dashboard': false,
      'max-upload-mb': 25,
      'invoice-layout': 'classic',
    })
    expect(service.value('max-upload-mb')).toBe(25)
  })

  it('targets by context', async () => {
    const service = createFlags(flags, {
      providers: [staticFlagsProvider((ctx) => ({ 'new-dashboard': ctx.userId === 'grace' }))],
    })
    expect((await service.resolve({ userId: 'grace' }))['new-dashboard']).toBe(true)
    expect((await service.resolve({ userId: 'linus' }))['new-dashboard']).toBe(false)
  })
})

To render a component with a flag set, boot a client app with the flags provider and use renderWithKitr from testing:

// app/components/dashboard/dashboard-page.test.tsx
import { defineConfig, staticFlagsProvider } from '@kitr/flags'
import flagsProvider from '@kitr/flags/provider'
import { createTestApp } from '@kitr/testing'
import { renderWithKitr } from '@kitr/testing/react'
import { screen } from '@testing-library/react'
import { expect, it } from 'vitest'
import { flags } from '#app/flags'
import { DashboardPage } from './dashboard-page'

// NewDashboard renders <h1>Your new dashboard</h1>.
it('shows the new dashboard when the flag is on', async () => {
  const app = await createTestApp({
    environment: 'client',
    providers: [flagsProvider],
    config: {
      flags: defineConfig({ definitions: flags, providers: [staticFlagsProvider({ 'new-dashboard': true })] }),
    },
  })
  renderWithKitr(<DashboardPage />, { app })
  expect(screen.getByRole('heading', { name: 'Your new dashboard' })).toBeTruthy()
})

To flip a flag mid-test, call app.container.use('flags').hydrate({ 'new-dashboard': false }) inside act().

Extend

A provider is an object with a name, an optional init(context) and resolve(key, context):

Member Called Returns
name In warnings A label, such as database.
init(context) Once per resolve(), before any key Nothing. A throw skips the provider for this resolve.
resolve(key, context) Once per declared flag A value, or undefined for "no opinion". May be async.

Use init to load everything a provider needs for one context, so resolve can answer from memory. This provider reads per-team overrides published as JSON at https://flags.acme.com/teams/<teamId>.json:

// app/flag_providers/team_overrides.ts
import type { FlagsProvider, FlagValue } from '@kitr/flags'

/** Overrides by team, from https://flags.acme.com/teams/<teamId>.json: { "new-dashboard": true }. */
export function teamOverridesProvider(baseUrl = 'https://flags.acme.com/teams'): FlagsProvider {
  const byTeam = new Map<string, Record<string, FlagValue>>()
  return {
    name: 'team-overrides',
    async init(context) {
      const teamId = context.teamId as string | undefined
      if (!teamId || byTeam.has(teamId)) return
      const response = await fetch(`${baseUrl}/${encodeURIComponent(teamId)}.json`)
      if (!response.ok) throw new Error(`flag overrides for ${teamId}: HTTP ${response.status}`)
      byTeam.set(teamId, (await response.json()) as Record<string, FlagValue>)
    },
    resolve(key, context) {
      const teamId = context.teamId as string | undefined
      return teamId ? byTeam.get(teamId)?.[key] : undefined
    },
  }
}

If the fetch fails, init throws and the next provider answers. This sketch caches teams for the life of the process; clear byTeam on a timer if overrides must change sooner. Register it in priority order:

// config/flags.ts
import { defineConfig, envFlagsProvider, percentageFlagsProvider } from '@kitr/flags'
import { flags } from '#app/flags'
import { teamOverridesProvider } from '#app/flag_providers/team_overrides'

export default defineConfig({
  definitions: flags,
  providers: [envFlagsProvider(), teamOverridesProvider(), percentageFlagsProvider({ 'new-dashboard': 10 })],
})

See FlagsProvider in the API reference.

Devtools

With devtools installed, every resolve or hydrate logs resolved with all values to the flags channel.

Next steps