Feature flags
Read feature flags, variants and remote config in the browser and on a server, target them, and render them without flicker.
A feature flag decides, per visitor or per signed-in person, whether a feature is on, which variant they see, or which configuration value applies. You define flags in MIRA FIVE; the SDKs fetch them as a flag document and evaluate every read locally, so a read is synchronous and never waits for the network. For the endpoints themselves, see Feature flags in the ingest API.
Flag documents
MIRA FIVE compiles the live flags of each source into a document. Which one an SDK receives depends on the source and the mode:
| Document | Served to | Content |
|---|---|---|
| Browser | Browser SDKs in full mode on a full website source | The rules of every flag the website reads |
| Values | Browser SDKs in consentless mode, and every consentless website source | The answer each flag gives someone the SDK knows nothing about |
| Server | Server SDKs, with the secret key | The rules of every flag servers read |
A flag only servers read never reaches a browser. Documents carry no flag names or descriptions, and a segment appears only as an opaque reference.
Flag types
| Type | Browser flag() returns | Server read |
|---|---|---|
| On/off | true or false | enabled(key) returns true or false |
| Variants | The variant key, for example 'b' | variant(key, fallback) returns the variant key |
| Remote config | The variant key | config(key, fallback) returns the variant's JSON value |
Any variant can carry a JSON value; config(key, fallback) returns the value of the variant the visitor gets, in the browser and on a server.
Each flag is assigned by one unit: by browser, using the anonymous id, or by person, using the user id you pass to identify() or to a server read. The same unit always lands in the same variant.
Read flags in the browser
Add flags to the client, then read them once they have loaded:
<script>window.mirafive=window.mirafive||function(){(mirafive.q=mirafive.q||[]).push(arguments)}</script>
<script defer src="https://cdn.mirafive.io/mira.js" data-key="mf_…" data-flags></script>
<script>
mirafive('flags', () => {
const newCheckout = mirafive('flag', 'new-checkout', false)
const limits = mirafive('config', 'checkout-limits', { maxItems: 10 })
document.body.classList.toggle('new-checkout', newCheckout === true)
document.body.dataset.maxItems = String(limits.maxItems)
})
</script>data-flags loads flags at once; without it they load on the first flag verb. Until then, flag and config return the fallback, so read them inside the flags listener. It runs once flags have loaded and again when they change.
Read flags on a server
Server SDKs fetch the server document with the secret key. Ask for one unit's flags, then read them:
import { Mira } from '@mirafive/sdk-server'
import { MiraFlags } from '@mirafive/sdk-server/flags'
const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY })
const flags = new MiraFlags({ key: process.env.MIRAFIVE_SECRET_KEY, mira })
const user = await flags.for({
userId: session.userId,
anonymousId: request.headers.get('X-Anonymous-Id') ?? undefined,
properties: { plan: session.plan },
consent: { experiments: true, targeting: false },
optedOut: request.headers.get('Sec-GPC') === '1' || request.headers.get('DNT') === '1',
})
const newCheckout = user.enabled('new-checkout')
const pricing = user.variant('pricing-test', 'control')
const limits = user.config('checkout-limits', { maxItems: 10 })Keep one MiraFlags per process. The first for() waits up to 1.5 seconds (timeoutMs) for the document; later reads use the one in memory.
The unit:
| Field | Description |
|---|---|
userId | Your id for the signed-in person. The unit of flags assigned by person |
anonymousId | The browser's anonymous id, from anonymousId() in the page. The unit of flags assigned by browser. Anything after a . is ignored |
properties | Facts your targeting rules test. Held in memory, never sent |
consent | The visitor's answer for experiments and targeting. A scope you leave out counts as granted: your own lawful basis applies |
optedOut | true for a request with Sec-GPC: 1 or DNT: 1: no ids, no segment lookup, no exposure |
Frameworks read the opt-out for you: flagsFor() in Next.js and TanStack Start, miraFlagsFor() in Nuxt and Astro, and Laravel.
Fallbacks
Every read takes a fallback, and returns it when there is no answer:
- flags have not loaded yet (in the browser), or no document has arrived (on a server);
- the flag is not served to this source, or does not exist. In development, the browser SDK warns:
[mirafive] flag "new-checkout" is unknown; - in the browser, a segment rule is waiting for its lookup (at most 800 ms, see Segments);
- the flag needs something this SDK version cannot evaluate.
A flag that is turned off is not a missing answer: it returns its default variant. Choose a fallback that is safe to ship: usually the current behaviour.
On a server, evaluate(key) explains an answer without counting an exposure. It returns the variant and a reason: STATIC, TARGETING_MATCH, SPLIT, DEFAULT or DISABLED. The reason ERROR means no variant, with the error code NOT_READY (no document yet), FLAG_NOT_FOUND or UNSUPPORTED. A variant can also carry an error code when facts were left out: NOT_ALLOWED (consent, or an experiment counted in the browser) or MEMBERSHIP_UNAVAILABLE (the segment lookup failed).
Targeting facts
Targeting rules test facts about the unit. In the browser the facts are:
$utm_source,$utm_mediumand$utm_campaignfrom the page URL, and$referrer_host, the referrer's host name. Each isnullwhen absent;- the traits you passed to
identify(); - the properties you set with
setFlagProperties(), which win over both.
mira.setFlagProperties({ plan: 'pro', country: 'DE' })On the script tag: mirafive('flagProperties', { plan: 'pro', country: 'DE' }). On a server, pass properties to the read. Flag properties are held in memory and never sent to MIRA FIVE. Each call to setFlagProperties() replaces the previous properties and notifies onFlags listeners.
Facts only change answers where the SDK evaluates rules: in full mode, and on servers. A consentless page receives the answer for everyone and ignores them.
Segments
A segment rule tests whether the unit is in one of your segments in MIRA FIVE. The SDK asks MIRA FIVE, since the membership lives there:
- Browser: in full mode with
targetingconsent, flags are fetched together with the membership of this browser's anonymous id. While that request is out, segment rules wait up to 800 ms, then count as "not in". - Server:
for()looks up the unit when a flag tests a segment, unlesstargetingisfalseor the unit is opted out. Lookups of one tick share one request of up to 100 units, and answers are kept for 1 minute.
Without an answer (no consent, no id, an opt-out, a failed lookup), both "in segment" and "not in segment" conditions are false.
Server-rendered pages
A page rendered on the server and read by the browser SDK afterwards may flip from the fallback to the real answer: the page flickers, or React reports a hydration mismatch. Hand the server's answers to the page in a bootstrap block:
<script type="application/json" id="mirafive-flags">{"v":1,"at":1727430000000,"values":{"new-checkout":["on"]}}</script>The browser SDK reads it once at start and answers from it at once. It ignores a block older than 7 days, and fetches the flags right away when the block is older than 60 seconds or lists flags only the browser can decide (a split by anonymous id, which the server does not know). The block holds only flags the website reads, never server-only values.
A response carrying the block is per visitor: send it with Cache-Control: private, no-store.
import { bootstrapHeaders } from '@mirafive/sdk-server/flags'
const user = await flags.for({ userId: session.userId })
const html = `<!doctype html><html><head>${user.bootstrap()}</head><body>…</body></html>`
return new Response(html, { headers: { 'Content-Type': 'text/html', ...bootstrapHeaders } })Print the block before the browser SDK's script. Values are escaped, so the block cannot end the script tag early.
Previews and overrides
To see a variant without being assigned to it, add mirafive-preview to the page URL. It takes flag-key:variant and can repeat:
https://shop.example/pricing?mirafive-preview=pricing-test:b&mirafive-preview=new-checkout:onOn/off flags take on or off. A variant the flag does not have is ignored. Previews apply in full mode, where the page has the flag's rules, and to page experiments. A preview is never counted as an exposure.
A preview link from MIRA FIVE may also carry mirafive-preview-token. The SDK forwards it unchanged; a valid token adds an experiment's unstarted draft to the flags for 24 hours.
In development, pin answers in code with overrides (full mode only, never counted):
flags({ overrides: { 'new-checkout': true, 'pricing-test': 'b' } })true means on, false means off. The browser answers in this order: override, preview, a page experiment that now shows everyone the original, a page-experiment snippet's decision, then the flag document.
Refresh
| SDK | Fetches the flags |
|---|---|
| Browser SDKs | At start (unless a fresh bootstrap block covers every flag), every refreshSeconds (default 300) while the tab is visible, when the tab becomes visible, when the URL changes, and when consent or the signed-in user changes what the segment lookup needs |
@mirafive/sdk-server | On a read, at most every refreshSeconds (default 30, at least 10), with If-None-Match. A failed refresh keeps the last document |
| PHP, Laravel, Symfony | On a read, at most every 30 seconds (flagsRefreshSeconds), shared through the cache when you pass one |
onFlags listeners and React hooks are not notified for a fetch that brings the same flags. An experiment keeps the variant it first showed for the rest of the page load, unless it is turned off. After a 401 or 403, a server SDK stops refreshing until restarted and keeps serving the last document.
Consent and flags
| Situation | What flags use |
|---|---|
| Consentless page | The answer for everyone. No rollouts by id, no targeting, no experiments |
| Full mode, no answer yet | No anonymous id, except experiments drawn at random before consent (see Experiments). Flags assigned by person use the user id from identify() |
experiments granted | The anonymous id, for rollouts and experiments; exposures are counted (with statistics) |
targeting granted | Segment membership |
Do Not Track, GPC, __mirafive_ignore | No ids, no segments, no exposures. Fixed answers and property rules still apply |
| Server read | consent.experiments: false uses no anonymous id and counts no experiment; consent.targeting: false looks up no segment |