TanStack Start
Add MIRA FIVE analytics and feature flags to a TanStack Start or TanStack Router app, in components and in server functions.
@mirafive/sdk-tanstack adds MIRA FIVE to a TanStack Start app: a client provider with the React hooks, and request middleware that puts a server client and a flag reader on context for server functions and server routes. The client provider also works in a TanStack Router app without Start; see TanStack Router without Start. For other React setups, use @mirafive/sdk-react.
Install
npm install @mirafive/sdk-tanstack @mirafive/sdk-react @mirafive/sdk-browser @mirafive/sdk-serverRequires React 18.3 or 19 and Node.js 20 or later. The /start entry also needs @tanstack/react-start 1.168 or later and @mirafive/sdk-server. The package is ESM only.
Set up
The browser and the server use different keys. See Keys for where to find them.
| Variable | Value | Read by |
|---|---|---|
VITE_MIRAFIVE_KEY | The website key of a website source (mf_…). Public. | Your code, which passes it to <MiraProvider websiteKey>. Vite inlines it at build time. |
MIRAFIVE_SECRET_KEY | The secret key of a server source (mf_…). Server only. | miraMiddleware(). |
MIRAFIVE_HOST | Optional. Default https://events.mirafive.io. | miraMiddleware(). |
VITE_MIRAFIVE_KEY=mf_…
MIRAFIVE_SECRET_KEY=mf_…Never give MIRAFIVE_SECRET_KEY a VITE_ prefix: Vite would ship it to every browser. MIRA FIVE marks a secret key that arrives from a browser as exposed, and you have to rotate it.
Register the middleware once, at module scope of src/start.ts. createStart() runs its factory per request, so do not create the middleware inside it:
import { miraMiddleware } from '@mirafive/sdk-tanstack/start'
import { createStart } from '@tanstack/react-start'
const mirafive = miraMiddleware()
export const startInstance = createStart(() => ({
requestMiddleware: [mirafive],
}))Wrap the root route's Outlet in MiraProvider. The package does not read import.meta.env itself, because Vite only replaces it in your own code, so pass the key:
import { MiraProvider } from '@mirafive/sdk-tanstack'
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import type { ReactNode } from 'react'
export const Route = createRootRoute({
shellComponent: RootDocument,
component: RootComponent,
})
function RootComponent() {
return (
<MiraProvider websiteKey={import.meta.env.VITE_MIRAFIVE_KEY}>
<Outlet />
</MiraProvider>
)
}
function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}That is the whole install for analytics. The provider creates the browser client once and adds the pageviews() plugin, which records the first page and every TanStack Router navigation through the Navigation API or the History API. You add no router subscription.
The client starts in consentless mode: it sets no cookies, stores nothing on the device and needs no consent banner.
Verify
- Deploy, or open the app on a host that is not
localhost. Events fromlocalhostare dropped unless you passtrackLocalhosttoMiraProvider. - In the browser's network tab, look for
POST https://events.mirafive.io/v1/batch/mf_…answering202with"accepted": 1. Batches leave 5 seconds after the first event, or when the tab is hidden. - Open the source's live view in MIRA FIVE and find the pageview.
To check the server side, send the reserved install-check event once from a server function:
import { createServerFn } from '@tanstack/react-start'
export const installCheck = createServerFn({ method: 'POST' }).handler(async ({ context }) => {
return context.mira.send([{ name: '$install_check' }])
})Call installCheck() once. With a working secret key and host it resolves with accepted: 0, dropped: 1 and reason: 'install_check', and MIRA FIVE records the check on the source. Remove it afterwards. More in Verify and debug.
Track events
In a component, read the client with useMira() and call track(name, properties):
import { useMira } from '@mirafive/sdk-tanstack'
export function SignupButton() {
const mira = useMira()
return <button onClick={() => mira.track('signup_clicked', { plan: 'pro' })}>Sign up</button>
}To record one event when a component appears, use useTrackOnMount(name, properties). It sends once, also under StrictMode. Event names, property limits and revenue are covered in Track events.
Server-side events
miraMiddleware() puts context.mira on every request: the server SDK's Mira, one per process for each key and host. track() only buffers; the middleware flushes once the response is ready, also when the handler throws.
import { createServerFn } from '@tanstack/react-start'
export const trackSignup = createServerFn({ method: 'POST' })
.validator((data: { userId: string }) => data)
.handler(async ({ context, data }) => {
context.mira.track('signup', { userId: data.userId, properties: { plan: 'pro' } })
})userId is your own pseudonymous id for the person, never an email address. Server events are sent in full mode: you decide the lawful basis for the ids you send. Server routes see the same context. Route loaders are isomorphic, so call a server function from them instead of using context.mira directly.
On Node.js the process keeps running and the flush completes on its own. On Cloudflare Workers or Vercel, pass the platform's waitUntil so the delivery outlives the response:
import { miraMiddleware } from '@mirafive/sdk-tanstack/start'
import { createStart } from '@tanstack/react-start'
import { waitUntil } from 'cloudflare:workers'
const mirafive = miraMiddleware({ waitUntil })
export const startInstance = createStart(() => ({
requestMiddleware: [mirafive],
}))On Vercel, import waitUntil from @vercel/functions instead. Events tracked while a streamed body is still rendering leave on the client's own one-second timer, which waitUntil does not cover: track in server functions and server routes, not during streaming. On runtimes without process.env, pass key and host to miraMiddleware(). For batching, retries and idempotency, see Server-side.
Identify users
Identifying users needs full mode and the identity() plugin. Pass both to the provider in the root route:
import { identity } from '@mirafive/sdk-browser/identity'
import { MiraProvider } from '@mirafive/sdk-tanstack'
import { Outlet } from '@tanstack/react-router'
function RootComponent() {
return (
<MiraProvider websiteKey={import.meta.env.VITE_MIRAFIVE_KEY} mode="full" plugins={[identity()]}>
<Outlet />
</MiraProvider>
)
}Then, in components:
const mira = useMira()
// From your consent manager's callback:
mira.consent({ statistics: true, experiments: true, targeting: false })
// After login:
mira.identify(user.id, { plan: user.plan })
// After logout:
mira.reset()Before a consent answer, full mode stores and sends nothing. Do Not Track, Global Privacy Control and prerendering send nothing from the browser. See Identify users and Consent.
Feature flags
Read flags in the server render and hand the answers to the browser, so the first paint already shows the right variant.
- A server function reads the visitor's flags with
context.flagsFor()and returns the bootstrap:
import { createServerFn } from '@tanstack/react-start'
export const getFlagBootstrap = createServerFn({ method: 'GET' }).handler(async ({ context }) => {
// Your own pseudonymous user id, when someone is signed in.
const flags = await context.flagsFor({ userId: undefined })
return flags.bootstrap()
})- The root route loads it and passes it to the provider, together with the
flags()plugin:
import { flags } from '@mirafive/sdk-browser/flags'
import { MiraProvider } from '@mirafive/sdk-tanstack'
import { createRootRoute, HeadContent, Outlet, Scripts } from '@tanstack/react-router'
import type { ReactNode } from 'react'
import { getFlagBootstrap } from '../flags'
export const Route = createRootRoute({
// The bootstrap only matters for the first server render.
loader: () => getFlagBootstrap(),
staleTime: Infinity,
shellComponent: RootDocument,
component: RootComponent,
})
function RootComponent() {
const bootstrap = Route.useLoaderData()
return (
<MiraProvider websiteKey={import.meta.env.VITE_MIRAFIVE_KEY} bootstrap={bootstrap} plugins={[flags()]}>
<Outlet />
</MiraProvider>
)
}
function RootDocument({ children }: { children: ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
)
}- Components read flags with
useFlaganduseFlagConfig:
import { useFlag, useFlagConfig } from '@mirafive/sdk-tanstack'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/checkout')({ component: Checkout })
function Checkout() {
const newCheckout = useFlag('new-checkout', false)
const { max } = useFlagConfig('limits', { max: 1 })
return <h1>{newCheckout === true ? `New checkout, up to ${max}` : 'Checkout'}</h1>
}The provider renders the bootstrap as the <script type="application/json" id="mirafive-flags"> block the browser SDK starts from, and the hooks render the same answers on the server and during hydration. The block only carries flags the website reads. A bootstrap older than 7 days is ignored. Do not render MiraFlagsScript as well.
context.flagsFor() reads Sec-GPC: 1 and DNT: 1 from the request and sets optedOut: no ids, no segment lookup, no exposure. A response whose request read flags gets Cache-Control: private, no-store, so one visitor's answers never sit in a shared cache. Exposures and refreshes go to waitUntil when you passed one.
To branch on a flag inside a server function, use the returned UserFlags directly: enabled(key, fallback?), variant(key, fallback?), config(key, fallback), evaluate(key). They are described in the server SDK. See Feature flags and Experiments.
TanStack Router without Start
A TanStack Router single-page app needs only @mirafive/sdk-tanstack, @mirafive/sdk-react and @mirafive/sdk-browser. Wrap the router in the provider:
import { MiraProvider } from '@mirafive/sdk-tanstack'
import { createRouter, RouterProvider } from '@tanstack/react-router'
import { createRoot } from 'react-dom/client'
import { routeTree } from './routeTree.gen'
const router = createRouter({ routeTree })
createRoot(document.getElementById('root')!).render(
<MiraProvider websiteKey={import.meta.env.VITE_MIRAFIVE_KEY}>
<RouterProvider router={router} />
</MiraProvider>,
)Pageviews, useMira() and the flag hooks work as above. Add plugins={[flags()]} for flags; without a server there is no bootstrap, so the hooks return their fallback until the browser SDK has loaded the flags.
API reference
@mirafive/sdk-tanstack
import { MiraProvider, useFlag, useFlagConfig, useMira, useTrackOnMount } from '@mirafive/sdk-tanstack'
import type { FlagBootstrap, MiraProviderProps } from '@mirafive/sdk-tanstack'| Export | Description |
|---|---|
<MiraProvider> | Creates the browser client on the first render in the browser and keeps it for the page's lifetime. Later prop changes are ignored, and a changed plugin set is warned about. Without a key it sends nothing and warns once, in every build. |
useMira<Events>() | The client. During a server render, an inert stand-in. See the browser SDK reference for its methods. |
useFlag(key, fallback) | string | boolean. The variant, or true/false for an on/off flag. |
useFlagConfig<T>(key, fallback) | T. The remote-config value of the flag's variant. |
useTrackOnMount(name, properties?) | Tracks one event when the component mounts. |
The hooks are re-exported from @mirafive/sdk-react.
MiraProvider props:
| Name | Type | Default | Description |
|---|---|---|---|
websiteKey | string | undefined | none | Required. The website key: import.meta.env.VITE_MIRAFIVE_KEY. Named websiteKey because React reserves key. |
host | string | https://events.mirafive.io | Where the browser sends events. |
mode | 'consentless' | 'full' | 'consentless' | 'full' needs identity() in plugins. |
plugins | Plugin[] | [] | Browser SDK plugins. pageviews() is added unless the list has one. |
flushAt | number | 20 | Send once this many events are queued. |
flushAfterMs | number | 5000 | Send this long after the first queued event. |
trackLocalhost | boolean | false | Also send from localhost, 127.*, [::1], *.local and file:. |
bootstrap | string | FlagBootstrap | none | flags.bootstrap() from context.flagsFor(), or a FlagBootstrap. Rendered as the mirafive-flags block and handed to the hooks. |
@mirafive/sdk-tanstack/start
import { miraMiddleware, MiraFlagsScript } from '@mirafive/sdk-tanstack/start'
import type { FlagUnit, MiraContext, MiraMiddlewareOptions, UserFlags } from '@mirafive/sdk-tanstack/start'| Export | Description |
|---|---|
miraMiddleware(options?) | Request middleware that puts mira and flagsFor on context. One Mira and one MiraFlags per process for each key and host, however often it is called; a failed start is retried by the next request. Flushes when the response is ready and sets Cache-Control: private, no-store when flags were read. |
<MiraFlagsScript flags> | The escaped mirafive-flags block, from a UserFlags or the string bootstrap() returned. Only for pages whose provider gets no bootstrap. |
miraMiddleware options:
| Name | Type | Default | Description |
|---|---|---|---|
key | string | process.env.MIRAFIVE_SECRET_KEY | The secret key. |
host | string | process.env.MIRAFIVE_HOST, else https://events.mirafive.io | Where events and flag requests go. |
waitUntil | (promise: Promise<unknown>) => void | none | The platform's waitUntil (cloudflare:workers, @vercel/functions), so deliveries outlive the response. |
MiraContext, what the middleware puts on context:
| Name | Type | Description |
|---|---|---|
mira | Mira | The server client. See the server SDK. |
flagsFor | (unit?: FlagUnit) => Promise<UserFlags> | One visitor's flags. Sec-GPC: 1 or DNT: 1 on the request sets optedOut. unit takes userId, anonymousId, properties, consent and optedOut, as in the server SDK. |
The package reads MIRAFIVE_SECRET_KEY and MIRAFIVE_HOST on the server, and the Sec-GPC and DNT request headers. It stores nothing. The middleware loads @mirafive/sdk-server lazily on the server, so it never reaches the browser bundle.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Nothing arrives | Events from localhost are off by default (trackLocalhost). Do Not Track or Global Privacy Control is on. VITE_MIRAFIVE_KEY was not set at build time. The site's origin is not allowed on the source. |
[mirafive] no website key in the console | Pass websiteKey={import.meta.env.VITE_MIRAFIVE_KEY} and set the variable before the build. |
403 secret_key_in_path or 403 website_key_as_bearer | The keys are swapped. The provider takes the website key, the middleware the secret key. |
403 origin_not_allowed | Add the site's domain to the source in MIRA FIVE. |
| A flag always returns its fallback | No flags() in plugins. The flag is not enabled for the website (a bootstrap carries only those). Experiments consent is missing. MIRAFIVE_SECRET_KEY is missing on the server: look for [mirafive] no key in the server logs. |
context.mira is undefined | miraMiddleware() is not in requestMiddleware in src/start.ts. |
| Server events lost on Workers or Vercel | Pass the platform's waitUntil to miraMiddleware(). |
Set up with an AI agent
Paste this into your coding agent:
Add MIRA FIVE analytics and feature flags to this TanStack Start app with @mirafive/sdk-tanstack.
Docs: https://docs.mirafive.io/sdks/tanstack-start.md
1. Install @mirafive/sdk-tanstack, @mirafive/sdk-react, @mirafive/sdk-browser and @mirafive/sdk-server
with the project's package manager.
2. Add to .env and the deployment's environment:
VITE_MIRAFIVE_KEY=<website key, mf_…>
MIRAFIVE_SECRET_KEY=<secret key of a server source>
Never prefix the secret key with VITE_ and never read it in a component.
3. In src/start.ts (create it if missing, keep existing middleware): const mirafive = miraMiddleware()
at module scope, with miraMiddleware from '@mirafive/sdk-tanstack/start', then
createStart(() => ({ requestMiddleware: [mirafive] })). Never create it inside the factory.
On Cloudflare Workers or Vercel pass the platform's waitUntil: miraMiddleware({ waitUntil }).
4. In src/routes/__root.tsx wrap the Outlet in
<MiraProvider websiteKey={import.meta.env.VITE_MIRAFIVE_KEY}> from '@mirafive/sdk-tanstack'.
It counts pageviews on every navigation; do not add router subscriptions.
5. Track in components with useMira().track(name, properties) or useTrackOnMount(name, properties).
Track on the server inside createServerFn handlers with context.mira.track(name, { userId, properties }).
6. For flags: a server function returning (await context.flagsFor({ userId })).bootstrap(), called from
the root route's loader with staleTime: Infinity. Pass bootstrap={bootstrap} plugins={[flags()]} to
MiraProvider (flags from '@mirafive/sdk-browser/flags'). Do not add <MiraFlagsScript> as well.
Read flags with useFlag(key, fallback) and useFlagConfig(key, fallback).
7. Keep the default consentless mode, which needs no consent banner. Only if the app already has a
consent manager and needs user ids: add identity() from '@mirafive/sdk-browser/identity' to
plugins, set mode="full", and call useMira().consent({ statistics, experiments, targeting })
from its callback.
8. Verify: run vite build; on a deployed page check the network tab for
POST https://events.mirafive.io/v1/batch/<key> answering 202. Server side, a server function running
await context.mira.send([{ name: '$install_check' }]) resolves with reason 'install_check'.
Report what you changed.
Do not add other analytics libraries, cookies or consent banners.