# TanStack Start

Source: https://docs.mirafive.io/sdks/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](https://docs.mirafive.io/sdks/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](#tanstack-router-without-start). For other React setups, use [`@mirafive/sdk-react`](https://docs.mirafive.io/sdks/react).

## Install

```bash
npm install @mirafive/sdk-tanstack @mirafive/sdk-react @mirafive/sdk-browser @mirafive/sdk-server
```

Requires 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](https://docs.mirafive.io/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()`. |

```sh title=".env"
VITE_MIRAFIVE_KEY=mf_…
MIRAFIVE_SECRET_KEY=mf_…
```

> **Warning:** 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:

```ts title="src/start.ts"
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:

```tsx title="src/routes/__root.tsx"
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](https://docs.mirafive.io/guides/consent): it sets no cookies, stores nothing on the device and needs no consent banner.

## Verify

1. Deploy, or open the app on a host that is not `localhost`. Events from `localhost` are dropped unless you pass `trackLocalhost` to `MiraProvider`.
2. In the browser's network tab, look for `POST https://events.mirafive.io/v1/batch/mf_…` answering `202` with `"accepted": 1`. Batches leave 5 seconds after the first event, or when the tab is hidden.
3. 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:

```ts title="src/install-check.ts"
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](https://docs.mirafive.io/guides/verify-and-debug).

## Track events

In a component, read the client with `useMira()` and call `track(name, properties)`:

```tsx title="src/components/SignupButton.tsx"
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](https://docs.mirafive.io/guides/track-events).

## Server-side events

`miraMiddleware()` puts `context.mira` on every request: the [server SDK](https://docs.mirafive.io/sdks/node)'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.

```ts title="src/signup.ts"
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:

```ts title="src/start.ts"
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](https://docs.mirafive.io/guides/server-side).

## Identify users

Identifying users needs [full mode](https://docs.mirafive.io/guides/consent#full-mode) and the `identity()` plugin. Pass both to the provider in the root route:

```tsx
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:

```tsx
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](https://docs.mirafive.io/guides/identify-users) and [Consent](https://docs.mirafive.io/guides/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.

1. A server function reads the visitor's flags with `context.flagsFor()` and returns the bootstrap:

```ts title="src/flags.ts"
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()
})
```

2. The root route loads it and passes it to the provider, together with the `flags()` plugin:

```tsx title="src/routes/__root.tsx"
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>
  )
}
```

3. Components read flags with `useFlag` and `useFlagConfig`:

```tsx title="src/routes/checkout.tsx"
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](https://docs.mirafive.io/sdks/node#feature-flags). See [Feature flags](https://docs.mirafive.io/guides/feature-flags) and [Experiments](https://docs.mirafive.io/guides/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:

```tsx title="src/main.tsx"
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`

```ts
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](https://docs.mirafive.io/sdks/browser#api-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`](https://docs.mirafive.io/sdks/react#api-reference).

`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`

```ts
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](https://docs.mirafive.io/sdks/node). |
| `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](https://docs.mirafive.io/sdks/node#feature-flags). |

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:

```text
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.
```
