MIRA FIVE

Next.js

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 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. For React without Next.js, use @mirafive/sdk-react.

Install

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 for where to find them.

VariableValueRead by
NEXT_PUBLIC_MIRAFIVE_KEYThe website key of a website source (mf_…). Public.<MiraProvider>. Next inlines it into the browser bundle at build time.
MIRAFIVE_SECRET_KEYThe secret key of a server source (mf_…). Server only.mira() and flagsFor() from @mirafive/sdk-next/server.
MIRAFIVE_HOSTOptional. Default https://events.mirafive.io.mira() and flagsFor().
.env.local
NEXT_PUBLIC_MIRAFIVE_KEY=mf_…
MIRAFIVE_SECRET_KEY=mf_…

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:

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

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:

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.

Track events

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

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.

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:

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:

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's Mira: identify(), send() with an idempotency key and with() work as described there. For batching, retries and idempotency, see Server-side.

Identify users

Identifying users needs 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:

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:

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. Three pieces:

  1. A client providers file that adds the flags() plugin and passes bootstrap on:
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>
  )
}
  1. 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:
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>
  )
}
  1. Client components read flags with useFlag and useFlagConfig:
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:

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. See Feature flags and 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 directly and hand the flush to the event's waitUntil:

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

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

ExportDescription
<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 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:

NameTypeDefaultDescription
websiteKeystringprocess.env.NEXT_PUBLIC_MIRAFIVE_KEYThe website key. Named websiteKey because React reserves key.
hoststringhttps://events.mirafive.ioWhere the browser sends events.
mode'consentless' | 'full''consentless''full' needs identity() in plugins.
pluginsPlugin[][]Browser SDK plugins. pageviews() is added unless the list has one. Set from a "use client" file.
flushAtnumber20Send once this many events are queued.
flushAfterMsnumber5000Send this long after the first queued event.
trackLocalhostbooleanfalseAlso send from localhost, 127.*, [::1], *.local and file:.
bootstrapstring | FlagBootstrapnoneflags.bootstrap() from flagsFor(), or a FlagBootstrap. Rendered as the mirafive-flags block and handed to the hooks.

@mirafive/sdk-next/server

import { flagsFor, mira, MiraFlagsScript } from '@mirafive/sdk-next/server'
import type { FlagUnit, UserFlags } from '@mirafive/sdk-next/server'
ExportDescription
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:

NameTypeDefaultDescription
userIdstringnoneYour own id for the signed-in person.
anonymousIdstringnoneThe browser SDK's anonymous id, in full mode.
propertiesRecord<string, Json>noneFacts for targeting rules. Held in memory, never sent.
consent{ experiments?: boolean, targeting?: boolean }grantedThe visitor's consent answer. Omitted scopes count as granted: your own lawful basis applies.
optedOutbooleanfrom the requestNo 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

SymptomCause and fix
Nothing arrivesEvents 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 consoleSet NEXT_PUBLIC_MIRAFIVE_KEY and rebuild, or pass websiteKey.
403 secret_key_in_path or 403 website_key_as_bearerThe keys are swapped. The provider takes the website key; mira() and flagsFor() take the secret key.
403 origin_not_allowedAdd the site's domain to the source in MIRA FIVE.
A flag always returns its fallbackNo 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 allmira() 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:

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.

On this page