# Next.js

Source: https://docs.mirafive.io/sdks/nextjs

> Add MIRA FIVE analytics and feature flags to a Next.js app, in client components and on the server, with flags rendered without a flicker.

`@mirafive/sdk-next` adds MIRA FIVE to a Next.js app: a client provider with the [React](https://docs.mirafive.io/sdks/react) hooks, and a server entry that sends events and reads feature flags in Server Components, route handlers and server actions. It is built for the App Router; the Pages Router works with the steps in [Pages Router](#pages-router). For React without Next.js, use [`@mirafive/sdk-react`](https://docs.mirafive.io/sdks/react).

## Install

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

Requires Next.js 15.1 or 16, React 18.3 or 19 and Node.js 20 or later. The package is ESM only. `@mirafive/sdk-server` is needed only for the server entry, `@mirafive/sdk-next/server`.

## 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 |
| --- | --- | --- |
| `NEXT_PUBLIC_MIRAFIVE_KEY` | The **website key** of a website source (`mf_…`). Public. | `<MiraProvider>`. Next inlines it into the browser bundle at build time. |
| `MIRAFIVE_SECRET_KEY` | The **secret key** of a server source (`mf_…`). Server only. | `mira()` and `flagsFor()` from `@mirafive/sdk-next/server`. |
| `MIRAFIVE_HOST` | Optional. Default `https://events.mirafive.io`. | `mira()` and `flagsFor()`. |

```sh title=".env.local"
NEXT_PUBLIC_MIRAFIVE_KEY=mf_…
MIRAFIVE_SECRET_KEY=mf_…
```

> **Warning:** Never give `MIRAFIVE_SECRET_KEY` a `NEXT_PUBLIC_` prefix. MIRA FIVE marks a secret key that arrives from a browser as exposed, and you have to rotate it.

Wrap the body of the root layout in `MiraProvider`:

```tsx title="app/layout.tsx"
import { MiraProvider } from '@mirafive/sdk-next'
import type { ReactNode } from 'react'

export default function RootLayout({ children }: { children: ReactNode }) {
  return (
    <html lang="en">
      <body>
        <MiraProvider>{children}</MiraProvider>
      </body>
    </html>
  )
}
```

That is the whole install for analytics. The provider creates the browser client once, from `NEXT_PUBLIC_MIRAFIVE_KEY`, and adds the `pageviews()` plugin, which records the first page and every App Router and Pages Router navigation. You add no `usePathname` effect and no `Suspense` boundary.

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. For feature flags, replace this provider with the one in [Feature flags](#feature-flags).

## 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, for example from a route handler:

```ts title="app/api/install-check/route.ts"
import { mira } from '@mirafive/sdk-next/server'

export async function GET() {
  const receipt = await mira().send([{ name: '$install_check' }])

  return Response.json(receipt)
}
```

With a working secret key and host it answers `"accepted": 0, "dropped": 1, "reason": "install_check"`, and MIRA FIVE records the check on the source. Remove the route afterwards. More in [Verify and debug](https://docs.mirafive.io/guides/verify-and-debug).

## Track events

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

```tsx title="app/signup-button.tsx"
'use client'

import { useMira } from '@mirafive/sdk-next'

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

`mira()` from `@mirafive/sdk-next/server` returns one server client per process, made from `MIRAFIVE_SECRET_KEY` and `MIRAFIVE_HOST`. `track()` only buffers. When you call `mira()` inside a request, it schedules a flush with Next's `after()`, so the events leave once the response has been sent and the response is not delayed.

In a route handler:

```ts title="app/api/signup/route.ts"
import { mira } from '@mirafive/sdk-next/server'

export async function POST(request: Request) {
  const { userId } = (await request.json()) as { userId: string }

  mira().track('signup', { userId, properties: { plan: 'pro' } })

  return Response.json({ ok: true })
}
```

In a server action:

```ts title="app/actions.ts"
'use server'

import { mira } from '@mirafive/sdk-next/server'

export async function upgrade(userId: string) {
  mira().track('plan_upgraded', { 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. Outside a request (module scope, a script), `after()` is unavailable and the client's own timer sends the buffer after one second.

The server entry imports `server-only`, so importing it from a `"use client"` file fails the build. `mira()` returns the [server SDK](https://docs.mirafive.io/sdks/node)'s `Mira`: `identify()`, `send()` with an idempotency key and `with()` work as described there. 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. Plugins are functions, so a Server Component cannot pass them: set them in a `"use client"` providers file and use that in the root layout instead of the plain `MiraProvider`:

```tsx title="app/providers.tsx"
'use client'

import { identity } from '@mirafive/sdk-browser/identity'
import { MiraProvider } from '@mirafive/sdk-next'
import type { ReactNode } from 'react'

export function Providers({ children }: { children: ReactNode }) {
  return (
    <MiraProvider mode="full" plugins={[identity()]}>
      {children}
    </MiraProvider>
  )
}
```

Then, in client 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. Three pieces:

1. A client providers file that adds the `flags()` plugin and passes `bootstrap` on:

```tsx title="app/providers.tsx"
'use client'

import { flags } from '@mirafive/sdk-browser/flags'
import { MiraProvider } from '@mirafive/sdk-next'
import type { ReactNode } from 'react'

export function Providers({ bootstrap, children }: { bootstrap: string; children: ReactNode }) {
  return (
    <MiraProvider bootstrap={bootstrap} plugins={[flags()]}>
      {children}
    </MiraProvider>
  )
}
```

2. The root layout reads the visitor's flags with `flagsFor()` and renders `Providers` in place of the plain `MiraProvider`. Use one provider per app, never nested:

```tsx title="app/layout.tsx"
import { flagsFor } from '@mirafive/sdk-next/server'
import type { ReactNode } from 'react'

import { Providers } from './providers'

export default async function RootLayout({ children }: { children: ReactNode }) {
  // Your own pseudonymous user id, when someone is signed in.
  const flags = await flagsFor({ userId: undefined })

  return (
    <html lang="en">
      <body>
        <Providers bootstrap={flags.bootstrap()}>{children}</Providers>
      </body>
    </html>
  )
}
```

3. Client components read flags with `useFlag` and `useFlagConfig`:

```tsx title="app/checkout/checkout.tsx"
'use client'

import { useFlag, useFlagConfig } from '@mirafive/sdk-next'

export 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.

`flagsFor()` reads `Sec-GPC: 1` and `DNT: 1` from the request and sets `optedOut`: no ids, no segment lookup, no exposure. Its refreshes and exposures are handed to `after()`.

To branch on a flag in a Server Component, use the `UserFlags` that `flagsFor()` returns:

```tsx title="app/pricing/page.tsx"
import { flagsFor } from '@mirafive/sdk-next/server'

export default async function PricingPage() {
  const flags = await flagsFor()

  return <h1>{flags.enabled('new-pricing') ? 'New pricing' : 'Pricing'}</h1>
}
```

`UserFlags` has `enabled(key, fallback?)`, `variant(key, fallback?)`, `config(key, fallback)`, `evaluate(key)` and `bootstrap()`, as 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).

## Caching

- `flagsFor()` reads request headers through `next/headers`, so the route that calls it renders per request. A bootstrap holds one visitor's answers; never put a flag read inside `"use cache"`.
- With `cacheComponents` (Next 16), a request-time read outside `<Suspense>` fails the build. Move `flagsFor()` and the provider that gets its bootstrap into a component wrapped in `<Suspense>`, or read flags in the page instead of the root layout.
- In a page with `export const dynamic = 'force-static'`, `headers()` is empty, so `flagsFor()` cannot see opt-outs. Leave such pages without a bootstrap; the browser SDK loads flags after the page starts.

## Middleware and edge

The server entry runs on the Node.js and the Edge runtime. In middleware, use the [server SDK](https://docs.mirafive.io/sdks/node) directly and hand the flush to the event's `waitUntil`:

```ts title="middleware.ts"
import { Mira } from '@mirafive/sdk-server'
import type { NextFetchEvent, NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

const mira = new Mira({ key: process.env.MIRAFIVE_SECRET_KEY, host: process.env.MIRAFIVE_HOST })

export function middleware(request: NextRequest, event: NextFetchEvent) {
  mira.track('api_request', { properties: { path: request.nextUrl.pathname } })
  event.waitUntil(mira.flush())

  return NextResponse.next()
}

export const config = { matcher: '/api/:path*' }
```

Next.js 16 names this file `proxy.ts` and the function `proxy`; the body stays the same. To read flags there, use `MiraFlags` from `@mirafive/sdk-server/flags` and set `optedOut` from the request's `Sec-GPC` and `DNT` headers yourself, as `flagsFor()` does.

## Pages Router

- Put `MiraProvider` in `pages/_app.tsx`. The hooks work unchanged, and `pageviews()` counts Pages Router navigations.
- Before you import `@mirafive/sdk-next/server`, add the package to `transpilePackages`. The Pages Router loads packages unbundled, and there `server-only` throws:

```ts title="next.config.ts"
import type { NextConfig } from 'next'

const config: NextConfig = {
  transpilePackages: ['@mirafive/sdk-next'],
}

export default config
```

- `after()` does not run in the Pages Router. In an API route, `await mira().flush()` before you return.
- `flagsFor()` needs the App Router. Read flags with `MiraFlags` and render the block in `pages/_document.tsx`:

```tsx title="pages/_document.tsx"
import { MiraFlagsScript } from '@mirafive/sdk-next/server'
import { bootstrapHeaders, MiraFlags } from '@mirafive/sdk-server/flags'
import Document, { type DocumentContext, Head, Html, Main, NextScript } from 'next/document'

const flags = new MiraFlags({ key: process.env.MIRAFIVE_SECRET_KEY, host: process.env.MIRAFIVE_HOST })

export default class MyDocument extends Document<{ bootstrap: string }> {
  static async getInitialProps(context: DocumentContext) {
    const initial = await Document.getInitialProps(context)
    const headers = context.req?.headers ?? {}
    const user = await flags.for({
      userId: undefined, // your own pseudonymous id, if signed in
      optedOut: headers['sec-gpc'] === '1' || headers['dnt'] === '1',
    })

    context.res?.setHeader('Cache-Control', bootstrapHeaders['Cache-Control'])
    return { ...initial, bootstrap: user.bootstrap() }
  }

  render() {
    return (
      <Html>
        <Head />
        <body>
          <MiraFlagsScript flags={this.props.bootstrap} />
          <Main />
          <NextScript />
        </body>
      </Html>
    )
  }
}
```

The browser SDK starts from that block; the hooks render their fallbacks on the server and switch after hydration. Only pages rendered per request (`getServerSideProps`) get a visitor's block. A statically optimized page gets one from build time, which the browser SDK refreshes because it is older than 60 seconds. To render flags on the server too, compute the bootstrap in `getServerSideProps`, pass it to `<MiraProvider bootstrap>` in `_app`, and drop `MiraFlagsScript`.

## API reference

### `@mirafive/sdk-next`

```ts
import { MiraProvider, useFlag, useFlagConfig, useMira, useTrackOnMount } from '@mirafive/sdk-next'
import type { FlagBootstrap, MiraProviderProps } from '@mirafive/sdk-next'
```

The client entry starts with `"use client"`.

| 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; in development 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` | `process.env.NEXT_PUBLIC_MIRAFIVE_KEY` | The website 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. Set from a `"use client"` file. |
| `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 `flagsFor()`, or a `FlagBootstrap`. Rendered as the `mirafive-flags` block and handed to the hooks. |

### `@mirafive/sdk-next/server`

```ts
import { flagsFor, mira, MiraFlagsScript } from '@mirafive/sdk-next/server'
import type { FlagUnit, UserFlags } from '@mirafive/sdk-next/server'
```

| Export | Description |
| --- | --- |
| `mira<Events>()` | `Mira<Events>`. One server client per process from `MIRAFIVE_SECRET_KEY` and `MIRAFIVE_HOST`, flushed with `after()` when called inside a request. |
| `flagsFor(unit?)` | `Promise<UserFlags>`. One visitor's flags from a process-wide `MiraFlags`. Sets `optedOut` from `Sec-GPC: 1` or `DNT: 1`. Makes the route dynamic. |
| `<MiraFlagsScript flags>` | The escaped `mirafive-flags` block, from a `UserFlags` or the string `bootstrap()` returned. Only for pages whose provider gets no `bootstrap`. |

`FlagUnit`:

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `userId` | `string` | none | Your own id for the signed-in person. |
| `anonymousId` | `string` | none | The browser SDK's anonymous id, in full mode. |
| `properties` | `Record<string, Json>` | none | Facts for targeting rules. Held in memory, never sent. |
| `consent` | `{ experiments?: boolean, targeting?: boolean }` | granted | The visitor's consent answer. Omitted scopes count as granted: your own lawful basis applies. |
| `optedOut` | `boolean` | from the request | No ids, no segment lookup, no exposure. |

The package reads `NEXT_PUBLIC_MIRAFIVE_KEY` in the browser, `MIRAFIVE_SECRET_KEY` and `MIRAFIVE_HOST` on the server, and the `Sec-GPC` and `DNT` request headers. It stores nothing.

## Troubleshooting

| Symptom | Cause and fix |
| --- | --- |
| Nothing arrives | Events from `localhost` are off by default (`trackLocalhost`). Do Not Track or Global Privacy Control is on. `NEXT_PUBLIC_MIRAFIVE_KEY` was not set at build time; Next inlines it then. The site's origin is not allowed on the source. |
| `[mirafive] no website key` in the console | Set `NEXT_PUBLIC_MIRAFIVE_KEY` and rebuild, or pass `websiteKey`. |
| `403 secret_key_in_path` or `403 website_key_as_bearer` | The keys are swapped. The provider takes the website key; `mira()` and `flagsFor()` take 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. |
| "Functions cannot be passed directly to Client Components" | `plugins` was passed from a Server Component. Move `<MiraProvider plugins={…}>` into a `"use client"` file. |
| Build error about `server-only` | `@mirafive/sdk-next/server` is imported from a client component. In the Pages Router, add `transpilePackages`. |
| Server events arrive late or not at all | `mira()` was called outside a request, or in the Pages Router. `await mira().flush()` there. |

## Set up with an AI agent

Paste this into your coding agent:

```text
Add MIRA FIVE analytics and feature flags to this Next.js app with @mirafive/sdk-next.
Docs: https://docs.mirafive.io/sdks/nextjs.md

1. Install @mirafive/sdk-next, @mirafive/sdk-react, @mirafive/sdk-browser and @mirafive/sdk-server
   with the project's package manager.
2. Add to .env.local and the deployment's environment:
     NEXT_PUBLIC_MIRAFIVE_KEY=<website key, mf_…>
     MIRAFIVE_SECRET_KEY=<secret key of a server source>
   Never prefix the secret key with NEXT_PUBLIC_ and never read it in a 'use client' file.
3. App Router: wrap the body of app/layout.tsx in <MiraProvider> from '@mirafive/sdk-next'.
   It counts pageviews on every navigation; do not add usePathname effects.
4. Track in client components with useMira().track(name, properties) or useTrackOnMount(name, properties).
   Track on the server with mira().track(name, { userId, properties }) from '@mirafive/sdk-next/server'
   in route handlers, server actions and Server Components; after() sends the events.
5. For flags: create app/providers.tsx ('use client') exporting Providers, which renders
   <MiraProvider bootstrap={bootstrap} plugins={[flags()]}> (flags from '@mirafive/sdk-browser/flags').
   In app/layout.tsx REPLACE the plain <MiraProvider> with it (one provider, never nested):
     const flags = await flagsFor({ userId })   // from '@mirafive/sdk-next/server'
     <Providers bootstrap={flags.bootstrap()}>
   Do not add <MiraFlagsScript> as well. If next.config has cacheComponents: true, wrap that part in
   <Suspense>. Do not read flags in force-static pages or inside 'use cache'.
   Read flags with useFlag(key, fallback) and useFlagConfig(key, fallback).
6. Pages Router: <MiraProvider> in pages/_app.tsx; add transpilePackages: ['@mirafive/sdk-next'] to
   next.config before importing '@mirafive/sdk-next/server'; await mira().flush() in API routes.
7. Keep the default consentless mode, which needs no consent banner. Only if the app already has a
   consent manager and needs user ids: in the providers file 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 next build; on a deployed page check the network tab for
   POST https://events.mirafive.io/v1/batch/<key> answering 202. Server side,
   await mira().send([{ name: '$install_check' }]) resolves with reason 'install_check'.
   Report what you changed.
Do not add other analytics libraries, cookies or consent banners.
```
