Browser (JavaScript)
Add MIRA FIVE analytics and feature flags to any bundled frontend with @mirafive/sdk-browser, one small import per feature.
@mirafive/sdk-browser is the JavaScript client for any frontend you bundle (Vite, webpack, esbuild, Rollup) without a MIRA FIVE framework package: Svelte, Solid, Angular, Lit or plain TypeScript. It is also the client the React and Vue packages wrap. No build step? Use the script tag. On Next.js, TanStack Start, Nuxt or Astro, use their framework packages.
Install
npm install @mirafive/sdk-browserThe package is ESM only, has no dependencies and is built for ES2022 browsers. Every feature is its own import, and what you do not import is not shipped:
| Import | min + gzip |
|---|---|
@mirafive/sdk-browser (createMira) | 2.48 kB |
with @mirafive/sdk-browser/pageviews | 2.76 kB |
@mirafive/sdk-browser/identity | 1.13 kB |
@mirafive/sdk-browser/autocapture | 0.86 kB |
@mirafive/sdk-browser/search | 0.40 kB |
@mirafive/sdk-browser/flags | 2.91 kB |
@mirafive/sdk-browser/experiments | 0.47 kB |
| everything together | 7.31 kB |
Each plugin is measured as a bundler adds it to a page that already has the core.
Set up
You need the website key of a website source (mf_…). See Keys for where to find it. Put it in your bundler's public env var:
VITE_MIRAFIVE_KEY=mf_…Create the client once, in code that runs only in the browser, and export it:
import { createMira } from '@mirafive/sdk-browser'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
export const mira = createMira({
key: import.meta.env.VITE_MIRAFIVE_KEY,
plugins: [pageviews()],
})Import that module from your entry point. pageviews() records the landing page and every client-side navigation for any router. You do not add a router hook.
The client starts in consentless mode: it sets no cookies, stores nothing on the device, never reads the browser's language, time zone or screen size, and needs no consent banner.
Never ship MIRAFIVE_SECRET_KEY to the browser. createMira throws for a secretKey option, and MIRA FIVE refuses a secret key in the batch URL and marks it as exposed.
Verify
- Open the app on its real domain. Events from
localhost,127.*,[::1],*.localandfile:are dropped unless you passtrackLocalhost: true. - In the browser's network tab, look for
POST https://events.mirafive.io/v1/batch/mf_…. Batches leave 5 seconds after the first event, after 20 events, or when the tab is hidden. Callmira.flush()to send at once. - It answers
202:
{ "batch": "…", "accepted": 1, "dropped": 0 }- Open the source's live view in MIRA FIVE and find the pageview.
Nothing arriving? See Troubleshooting.
Track events
Call track(name, properties) where the thing happens:
import { mira } from './mira'
document.querySelector('#signup')?.addEventListener('submit', () => {
mira.track('signup', { plan: 'pro' })
})Names are 1 to 128 characters, cannot start with $ and cannot start or end with whitespace. The core drops an event the server would refuse, with a development warning, so it cannot take its batch down: properties over 32 KB of JSON, over 64 leaf values, deeper than 5 levels, a key over 128 characters, or a string with a lone surrogate. Event names, property limits and revenue are covered in Track events.
Name your events in a type to have TypeScript check every call. Typed events are types only and add nothing to the bundle:
import { createMira } from '@mirafive/sdk-browser'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
type Events = {
signup: { plan: string }
logout: undefined
}
export const mira = createMira<Events>({
key: import.meta.env.VITE_MIRAFIVE_KEY,
plugins: [pageviews()],
})
mira.track('signup', { plan: 'pro' }) // checked
mira.track('logout') // properties optionalTo record clicks, form submits and field changes without code, add autocapture():
import { autocapture } from '@mirafive/sdk-browser/autocapture'
const mira = createMira({ key: import.meta.env.VITE_MIRAFIVE_KEY, plugins: [pageviews(), autocapture()] })It sends $autocapture for a, button, input, select, textarea and elements with a button, link, tab or menuitem role. It records the tag, a short selector, the id and classes (generated names skipped), link and button text, the cleaned href, name, type, and data-testid, data-test, data-cy, data-qa, data-track plus any selectorAttributes you pass. It never records what a visitor typed, and skips password, email and hidden inputs and everything inside [data-mira-no-capture]. Autocapture works in both modes.
Identify users
Identifying users needs full mode and the identity() plugin:
import { createMira } from '@mirafive/sdk-browser'
import { identity } from '@mirafive/sdk-browser/identity'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
export const mira = createMira({
key: import.meta.env.VITE_MIRAFIVE_KEY,
mode: 'full',
plugins: [pageviews(), identity()],
})// After login, once the visitor granted statistics consent
mira.identify(user.id, { plan: user.plan })
// After logout
mira.reset()identify sends $identify and puts the user id on every later event. Ids are 1 to 256 characters; numbers are turned into strings. When a different user signs in on the same browser, the anonymous and session ids start fresh.
Events before statistics consent are dropped, $identify included. The user id is kept in memory and stamped on later events, but call identify again after the grant if you need the $identify event.
To link server-side events to the visitor, send mira.anonymousId() to your server. It is undefined without statistics consent. See Identify users.
Consent
In full mode, nothing is sent and nothing is stored until the visitor answers. Pass the answer with consent():
| Call | Effect |
|---|---|
consent(true) | Grants statistics only. |
consent({ statistics, experiments, targeting }) | Answers by scope. A scope you leave out keeps its last answer. |
consent(false) | Forgets the ids and the user, and clears the queue. |
The landing pageview is sent when consent is first granted, so a visitor who accepts on the first page still counts that page. Wire your consent manager's callback to it. statistics is the usual "analytics" category, targeting the "marketing" one:
import { mira } from './mira'
// Cookiebot
window.addEventListener('CookiebotOnConsentReady', () => {
const { statistics, preferences, marketing } = Cookiebot.consent
mira.consent({ statistics, experiments: preferences, targeting: marketing })
})
// OneTrust (default group ids: C0002 performance, C0003 functional, C0004 targeting)
window.OptanonWrapper = () => {
const groups = window.OnetrustActiveGroups ?? ''
mira.consent({
statistics: groups.includes(',C0002,'),
experiments: groups.includes(',C0003,'),
targeting: groups.includes(',C0004,'),
})
}If the answer is known before the client starts, set window.__mirafive_consent = { statistics, experiments, targeting } (or false) first. identity() applies it at creation, so the landing pageview is counted with the answer ($boot: 1).
Full mode stores, in localStorage only and never in cookies, mirafive:{namespace}:aid (the anonymous id, kept 365 days since last seen), :sid (the session, 30 minutes idle) and :uid (a hash of the user id). The namespace is the key without its last _… part.
Do Not Track, Global Privacy Control, window.__mirafive_ignore = true (set it for your own visits) and prerendering send nothing in either mode. A prerendered page sends once it is shown. See Consent.
Feature flags
Add flags(), then read flags in an onFlags listener. It runs once flags have loaded, and again whenever they change:
import { createMira } from '@mirafive/sdk-browser'
import { flags } from '@mirafive/sdk-browser/flags'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
export const mira = createMira({
key: import.meta.env.VITE_MIRAFIVE_KEY,
plugins: [pageviews(), flags()],
})
mira.onFlags(() => {
const newCheckout = mira.flag('new-checkout', false)
const limits = mira.config('checkout-limits', { maxItems: 10 })
document.body.classList.toggle('new-checkout', newCheckout === true)
document.querySelector('#max-items')!.textContent = String(limits.maxItems)
})flag(key, fallback)returns the variant key, ortrue/falsefor an on/off flag. It returns the fallback until flags load, and for a flag this source does not have (with a development warning).config(key, fallback)returns the remote-config value of the variant.setFlagProperties(properties)passes facts for targeting rules. They stay in memory and are never sent.
The plugin fetches the source's flags at start, every refreshSeconds (300) while the page is visible, when the page becomes visible again and when the URL changes. In consentless mode it fetches only the answers; in full mode it evaluates rules in the browser, and with targeting consent it also looks up the visitor's segments. If the page has a <script type="application/json" id="mirafive-flags"> block from a server SDK, the first read already has its answers: see Feature flags.
To preview a variant, open the page with ?mirafive-preview=new-checkout:b. To pin answers in development, pass overrides (full mode only): flags({ overrides: { 'new-checkout': true } }). Previews and overrides are never counted.
Experiments
Code experiments are flags: read them with flag(). In full mode, with statistics and experiments consent, the client sends one $exposure per flag and page load for experiments counted in the browser. An experiment keeps the variant the page first showed until the page reloads, unless the experiment is turned off.
Page experiments are drawn before the page renders by a head snippet that MIRA FIVE generates. To count them, add experiments() after flags(), in full mode:
import { createMira } from '@mirafive/sdk-browser'
import { experiments } from '@mirafive/sdk-browser/experiments'
import { flags } from '@mirafive/sdk-browser/flags'
import { identity } from '@mirafive/sdk-browser/identity'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
export const mira = createMira({
key: import.meta.env.VITE_MIRAFIVE_KEY,
mode: 'full',
plugins: [pageviews(), identity(), flags(), experiments()],
})It sends $exposure for each page experiment the snippet drew (window.__mirafive_experiments), once the experiments scope is granted. See Experiments.
Single-page apps
pageviews()listens to the Navigation API'snavigatesuccesswhere the browser has it, else patchespushStateandreplaceStateand listens topopstate. The title is read one task after the URL changes, so a router has time to set it.- A navigation that leaves the cleaned URL unchanged is not a new view. The cleaned URL keeps only
utm_*,ref,sourceand click-id parameters, and drops the fragment. - Hash routing (
/#/pricing): passpageviews({ hash: true }). Hash changes become pageviews and the fragment is kept. - After the first view, the referrer is the previous page.
- For full control, leave out
pageviews()and callmira.pageview()after each route change.
Create one client per page. A second createMira while one is running stays inert and warns in development. destroy() stops timers and listeners, undoes the history patch, drops the queue and releases the page, so a component that creates the client on mount and destroys it on unmount (React StrictMode mounts twice) works. createMira needs window: do not call it during a server render.
Writing a plugin
A plugin is { name, setup(core) }. setup may return a teardown that destroy() runs. Plugins given to createMira are set up in order during creation; use() adds one later.
import type { Plugin } from '@mirafive/sdk-browser'
export const outboundLinks = (): Plugin => ({
name: 'outbound-links',
setup(core) {
const onClick = (event: MouseEvent) => {
const link = (event.target as Element | null)?.closest('a')
if (link && link.host !== location.host) {
core.send('outbound_link', { host: link.host }, { url: core.clean(location.href) })
}
}
document.addEventListener('click', onClick, true)
return () => document.removeEventListener('click', onClick, true)
},
})setup receives MiraCore:
| Member | Description |
|---|---|
options | The options as given, with host (no trailing slash) and mode resolved. |
state | Shared state: mode, context, page, and what plugins own (consent, user, aid(), flags). Write only the parts your plugin owns. |
client | The Mira client the site holds. |
send(name, properties?, page?) | Queues an event. Opt-outs, the full-mode gate and beforeSend hooks apply. |
expose(methods) | Adds methods to the client, replacing those of the same name. |
on(hook, listener): () => void | Subscribes to a hook. Returns an unsubscribe function. |
emit(hook, argument?): boolean | Runs a hook's listeners; false when one returned false. |
ready(run) | Runs in a microtask: after every plugin given at creation is set up, or right after a late use(). |
flush(unload?) | Sends the queue; true uses sendBeacon, as on page hide. |
clear() | Empties the queue and the hold buffer, and cancels batches waiting for a retry. |
hold(), release(keep) | Keeps up to 100 events while full mode waits for identity; release(true) queues them, release(false) drops them. |
clean(url) | The URL cleaned as pageviews are. |
cut(text, max) | Cuts text to max UTF-16 units without splitting a surrogate pair. |
uuid() | A v4 UUID. |
warn(message) | A development warning, once per message, on local hosts only. |
optedOut() | true under Do Not Track, Global Privacy Control, __mirafive_ignore or prerendering. |
| Hook | Argument | When |
|---|---|---|
beforeSend | The event. Change it in place; return false to drop it. | As each event is queued. |
pageview | The page URL before cleaning. | After each pageview. |
consent | The consent answer. | After each answer (identity). |
user | none | After identify() and reset() (identity). |
flags | none | After flags load or change (flags). |
flush | none | Before each flush, including the one on page hide. |
In mode 'full' the core sends nothing until state.mode is 'full', whichever plugins are present.
API reference
import { createMira } from '@mirafive/sdk-browser'
import { autocapture } from '@mirafive/sdk-browser/autocapture'
import { experiments } from '@mirafive/sdk-browser/experiments'
import { flags } from '@mirafive/sdk-browser/flags'
import { identity } from '@mirafive/sdk-browser/identity'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
import { siteSearch } from '@mirafive/sdk-browser/search'There are no default exports.
createMira
createMira<Events>(options): Mira<Events>
| Name | Type | Default | Description |
|---|---|---|---|
key | string | none | Required. The source's website key (mf_…). |
host | string | https://events.mirafive.io | Events host, with scheme. For a first-party proxy. |
mode | 'consentless' | 'full' | 'consentless' | 'full' needs identity() in plugins. |
plugins | Plugin[] | [] | Set up in order during creation. |
flushAt | number | 20 | Send once this many events are queued, 1 to 1000. Each batch also stays under about 48 KB. |
flushAfterMs | number | 5000 | Send this long after the first queued event, 50 to 300000. |
trackLocalhost | boolean | false | Also send from localhost, 127.*, [::1], *.local and file:. |
A flushAt or flushAfterMs out of range is clamped with a development warning. createMira throws a TypeError for a secretKey option, a host without a scheme, and mode: 'full' without identity(). Nothing else throws: transport problems drop the batch with a development warning.
Client members
| Member | Plugin | Description |
|---|---|---|
track(name, properties?) | core | Queues an event. Names starting with $ are reserved. |
pageview(page?) | core | Queues $pageview for the current page, or { url?, title?, referrer? }. The referrer defaults to the previous pageview's URL, then document.referrer. |
flush(): Promise<void> | core | Sends the queue now. |
use(plugin) | core | Adds a plugin after creation. |
destroy() | core | Stops timers and listeners, undoes patches, drops the queue and lets a new client start. |
consent(answer) | identity | true, false or { statistics?, experiments?, targeting? }. See Consent. |
identify(userId, traits?) | identity | Sends $identify, then userId on later events. A different user starts fresh ids. |
reset() | identity | Forgets ids, user and session. |
anonymousId(): string | undefined | identity | The anonymous id; undefined without statistics consent. |
search(query) | search | Queues $search. |
flag(key, fallback): string | boolean | flags | The variant, or true/false for an on/off flag. |
config<T>(key, fallback: T): T | flags | The variant's remote-config value. |
onFlags(listener): () => void | flags | Runs when flags load or change, at once if loaded. Returns an unsubscribe function. |
setFlagProperties(properties) | flags | Facts for targeting rules, held in memory and never sent. |
A member whose plugin is missing warns once in development and does nothing: flag and config return the fallback, onFlags returns a no-op unsubscribe.
Plugins
| Plugin | Import | Description |
|---|---|---|
pageviews(options?) | @mirafive/sdk-browser/pageviews | The landing pageview and every same-document navigation. |
identity() | @mirafive/sdk-browser/identity | Consent, ids and the signed-in user. Required for mode 'full'. |
autocapture(options?) | @mirafive/sdk-browser/autocapture | $autocapture for clicks, submits and changes. Both modes. |
siteSearch(options?) | @mirafive/sdk-browser/search | $search from the page URL and from search(query). Mode 'full' only, after consent. |
flags(options?) | @mirafive/sdk-browser/flags | Feature flags, remote config and code experiments. |
experiments() | @mirafive/sdk-browser/experiments | $exposure for page experiments. Mode 'full', after flags() and with identity(). |
pageviews options:
| Name | Type | Default | Description |
|---|---|---|---|
hash | boolean | false | The fragment is the route (#/pricing): hash changes are pageviews and the fragment is kept. |
initial | boolean | true | Send the landing pageview, a microtask after creation. |
autocapture options:
| Name | Type | Default | Description |
|---|---|---|---|
selectorAttributes | string[] | [] | More attributes to report in $el_attrs, besides data-testid, data-test, data-cy, data-qa and data-track. |
siteSearch options:
| Name | Type | Default | Description |
|---|---|---|---|
parameters | string[] | ['q', 's', 'search', 'query'] | URL parameters that carry the search term. The term is read before URL cleaning and sent after 1 second without a change, or when the visitor moves on or leaves. |
flags options:
| Name | Type | Default | Description |
|---|---|---|---|
bootstrap | FlagBootstrap | none | Answers until the first fetch. A #mirafive-flags block on the page wins. Ignored when older than 7 days. |
overrides | Record<string, string | boolean> | {} | Fixed answers for development, mode 'full' only, never counted. true is on, false is off. |
refreshSeconds | number | 300 | Refetch interval while the page is visible. |
Types
import type {
ConsentAnswer,
EventMap,
FlagBootstrap,
FlagState,
Json,
Mira,
MiraCore,
MiraEvent,
MiraHooks,
MiraOptions,
MiraState,
Mode,
Page,
Plugin,
Properties,
} from '@mirafive/sdk-browser'| Type | Description |
|---|---|
Mira<Events> | The client createMira returns. |
MiraOptions | The options of createMira. |
EventMap | Record<string, Properties | undefined>: event name to its properties; undefined makes them optional. |
ConsentAnswer | { statistics?, experiments?, targeting? }. |
Page | { url?, title?, referrer? }. |
Properties | Record<string, unknown>. |
Mode | 'consentless' | 'full'. |
FlagBootstrap | The bootstrap a server SDK renders for the flags() plugin. |
Json | A JSON value, the type of remote config. |
Plugin, MiraCore, MiraHooks, MiraState, MiraEvent, FlagState | The plugin surface. See Writing a plugin. |
The plugin entries also export their option types: PageviewsOptions, AutocaptureOptions, SiteSearchOptions and FlagsOptions.
Batches are text/plain POSTs to {host}/v1/batch/{key}, so they need no CORS preflight. On page hide they go by navigator.sendBeacon. A failed send is tried up to three times in all, with backoff, honouring Retry-After up to 10 seconds. The client reports mirafive-browser/1.0.0 as the SDK. If your site sends a Content Security Policy, add connect-src https://events.mirafive.io (or your host).
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Nothing arrives | Events from local hosts are off by default (trackLocalhost: true). Do Not Track, Global Privacy Control or __mirafive_ignore is on. In mode 'full', consent() has not run. Check host. |
TypeError: [mirafive] mode "full" needs identity() | Add identity() to plugins. |
TypeError: [mirafive] host needs a scheme | Write host with https://. |
[mirafive] second client stays inert | createMira ran twice on the page. Create one client, or destroy() the first. |
[mirafive] consent() needs its plugin (or another member) | The member's plugin is not in plugins. |
403 secret_key_in_path | You passed a secret key. Use the website key of a website source. |
403 origin_not_allowed | Add the site's origin to the source's allowed origins in MIRA FIVE. |
400 collection_mode_not_allowed | mode: 'full' on a consentless source. Switch the source to full mode or remove mode. |
| A flag always returns its fallback | Flags have not loaded yet (read in onFlags), the flag is not enabled for this source (a development warning says so), a segment rule lacks targeting consent, or a user flag was read before identify(). |
| An experiment never counts | It needs mode 'full' with statistics and experiments consent. Previews and overrides are never counted. |
Development warnings appear only on local hosts. Pass trackLocalhost: true to see events and warnings while you develop.
Set up with an AI agent
Paste this into your coding agent:
Add MIRA FIVE analytics to this bundled frontend with @mirafive/sdk-browser.
Docs: https://docs.mirafive.io/sdks/browser.md
1. Install @mirafive/sdk-browser with the project's package manager. If the app uses React,
Next.js, TanStack Start, Vue, Nuxt or Astro, use that MIRA FIVE package instead (see the docs).
2. Put the source's website key (mf_…) in the bundler's public env var: VITE_MIRAFIVE_KEY,
NEXT_PUBLIC_MIRAFIVE_KEY, PUBLIC_MIRAFIVE_KEY or NUXT_PUBLIC_MIRAFIVE_KEY.
Never put MIRAFIVE_SECRET_KEY in browser code.
3. In a module that runs once and only in the browser, add:
import { createMira } from '@mirafive/sdk-browser'
import { pageviews } from '@mirafive/sdk-browser/pageviews'
export const mira = createMira({ key: <the env var>, plugins: [pageviews()] })
Add autocapture() from '@mirafive/sdk-browser/autocapture' if clicks should be counted,
and flags() from '@mirafive/sdk-browser/flags' if the app reads feature flags.
4. Do not add router hooks for pageviews; pageviews() covers every router.
5. Keep the default consentless mode: it needs no consent banner. Only if the site already has a
consent manager and wants user ids: add identity() from '@mirafive/sdk-browser/identity',
pass mode: 'full', and call mira.consent({ statistics, experiments, targeting }) from the
consent manager's callback.
6. Verify: load a page on a non-localhost domain (or pass trackLocalhost: true), call
mira.flush(), and check the network tab for POST https://events.mirafive.io/v1/batch/<key>
answering 202. Report what you changed.
Do not add other analytics libraries, cookies or consent banners.