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-serverRequires 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.
| 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(). |
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:
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
- 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, for example from a route handler:
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):
'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:
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:
'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:
'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:
- A client providers file that adds the
flags()plugin and passesbootstrapon:
'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>
)
}- The root layout reads the visitor's flags with
flagsFor()and rendersProvidersin place of the plainMiraProvider. Use one provider per app, never nested:
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>
)
}- Client components read flags with
useFlaganduseFlagConfig:
'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:
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 throughnext/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. MoveflagsFor()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, soflagsFor()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:
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
MiraProviderinpages/_app.tsx. The hooks work unchanged, andpageviews()counts Pages Router navigations. - Before you import
@mirafive/sdk-next/server, add the package totranspilePackages. The Pages Router loads packages unbundled, and thereserver-onlythrows:
import type { NextConfig } from 'next'
const config: NextConfig = {
transpilePackages: ['@mirafive/sdk-next'],
}
export default configafter()does not run in the Pages Router. In an API route,await mira().flush()before you return.flagsFor()needs the App Router. Read flags withMiraFlagsand render the block inpages/_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".
| 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 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 | 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
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:
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.