Use Optimizely Feature Experimentation Feature Flags in Next.js

David Sertillange, Independent experimentation specialistDavid SertillangeIndependent experimentation specialist
·5 min read

Next.js is the runtime where a feature flag stops being one thing. The same flag is read in a server component that renders once per request, in a route handler that may be cached, and in a client component that hydrates in the browser — three places with three lifetimes, three identities available to them, and three different ways to get the answer wrong.

This guide covers reading Optimizely Feature Experimentation flags in the App Router: which SDK goes where, how to keep the server and the client from disagreeing, what caching does to a decision, and where the impression is actually recorded. If the flag is carrying an experiment, size it on the sample size calculator first and read how long to run an A/B test before you promise a decision date.

Where a Flag Can Be Read in the App Router

flowchart TD
    A[Request] --> B[Middleware: read or set the visitor id cookie]
    B --> C[Server component]
    C --> D[Node SDK client, cached per process]
    D --> E[decide with the cookie id]
    E --> F[HTML rendered for the decided variation]
    F --> G[Client components hydrate]
    G --> H{Client needs the flag too?}
    H -->|yes| I[Pass the decision down as a prop]
    H -->|no| J[Nothing further]

The rule that keeps all of this coherent: the server decides, the client is told. A client component that decides for itself will sometimes disagree with the HTML the server already sent, and React resolves that disagreement by throwing your markup away.

Prerequisites

  • An Optimizely Feature Experimentation project and the SDK key for the environment.

  • A flag created in the Optimizely UI.

  • @optimizely/optimizely-sdk added to the project — the JavaScript SDK is the one that runs in the Node.js runtime.

  • A visitor id cookie, because a server render has no browser storage to read. Middleware is where it is created.

  • Node.js runtime for any route that decides. The Edge runtime has no setInterval-driven polling and no long-lived process to hold a datafile.

Step 1: Give Every Visitor a Stable Id in Middleware

// middleware.ts
import { NextResponse, type NextRequest } from 'next/server'

const COOKIE = 'optimizely_visitor_id'

export function middleware(request: NextRequest) {
  const existing = request.cookies.get(COOKIE)?.value
  const response = NextResponse.next()

  if (!existing) {
    response.cookies.set(COOKIE, crypto.randomUUID(), {
      maxAge: 60 * 60 * 24 * 365,
      sameSite: 'lax',
      path: '/',
    })
  }

  return response
}

Setting the id here rather than in the page matters: the page needs the id to decide, and a cookie set during render is not readable until the next request. A visitor whose id is minted late is bucketed late, which shows up as a first pageview that always sees the control.

Step 2: Create One Client Per Server Process

// lib/optimizely.ts
import { createInstance } from '@optimizely/optimizely-sdk'

let client: ReturnType<typeof createInstance> | null = null

export async function getOptimizely() {
  if (!client) {
    client = createInstance({
      sdkKey: process.env.OPTIMIZELY_SDK_KEY,
      datafileOptions: { autoUpdate: true, updateInterval: 300000 },
    })
  }
  await client.onReady({ timeout: 3000 })
  return client
}

The module-level cache is deliberate. Next.js keeps the module registry alive across requests in a warm server, so this gives you one datafile and one event queue per process. Creating the client inside the component instead means a datafile fetch per render.

onReady with a timeout is what stops a cold start from rendering the off state. Choose a timeout you are willing to add to time-to-first-byte; if it expires, you get the off state, which is the safe answer but should be logged rather than ignored.

Step 3: Decide in a Server Component

// app/checkout/page.tsx
import { cookies } from 'next/headers'
import { getOptimizely } from '@/lib/optimizely'

export const dynamic = 'force-dynamic'

export default async function CheckoutPage() {
  const visitorId = (await cookies()).get('optimizely_visitor_id')?.value
  const optimizely = await getOptimizely()

  const user = optimizely.createUserContext(visitorId ?? 'anonymous', {
    country: 'US',
  })
  const decision = user.decide('checkout_redesign')

  return decision.enabled ? (
    <NewCheckout label={decision.variables.label} />
  ) : (
    <LegacyCheckout />
  )
}

dynamic = 'force-dynamic' is not optional here, and it is the single most important line in this article. Reading cookies() already opts the route out of static rendering, but stating it makes the intent legible to the next person — and to the reviewer who is about to add a revalidate and quietly turn your experiment into a cached coin flip.

Step 4: Do Not Cache a Decided Response

A statically rendered or ISR-cached route renders once and serves that HTML to everyone. If the render decided a flag, the first visitor's variation is served to all subsequent visitors, and your experiment reports a 50/50 split that never happened.

Three safe patterns:

  • Dynamic render for pages that differ by variation (above). Simple, costs you the cache.

  • Cache the shell, decide in a hole. Keep the page static and render the varying part in a dynamic child inside <Suspense>, so only that fragment is uncached.

  • Decide at the edge, rewrite to a variant path. Middleware picks the variation and rewrites /checkout to /checkout/variant-b; both paths stay cacheable because each one is a single variation. The impression must then be recorded server-side on the variant route, not in middleware.

Whichever you choose, fetch calls inside a decided route inherit Next.js caching too. A fetch that returns variation-specific data needs cache: 'no-store', or two visitors in two variations will share one response.

Step 5: Give the Client the Same Answer

Client components should not decide again. Pass the decision down:

<CheckoutForm variation={decision.variationKey} label={decision.variables.label} />

If a client component genuinely needs the SDK — for a decision that only exists after an interaction — initialize a browser client with the datafile the server already has, serialised into the page, and give it the same visitor id from the cookie. Identical id plus identical datafile means identical bucketing, which is what keeps hydration quiet.

Step 6: Flush Events Before the Process Dies

Serverless functions freeze between invocations, and a frozen function's event queue is not dispatched. In an environment where the process can be suspended at any moment, either wait for the dispatch before responding or use waitUntil where your platform provides it. On a long-lived Node server, register client.close() on SIGTERM so a deploy does not discard the last batch.

Verifying the Integration

  1. Two cookies, two variations. Request the page with two different optimizely_visitor_id values and confirm the HTML differs. Do it with curl, not the browser — the browser will happily hide the problem behind its own cache.

  2. Same cookie, ten requests. The same id must produce the same variation every time. If it does not, the decision is being made somewhere without the cookie.

  3. Check the response headers.x-nextjs-cache: HIT on a decided route means step 4 is not done.

  4. Watch the dispatch. Log the SDK's event dispatcher in preview and confirm impressions leave the server.

Common Failure Modes

  • A cached decided route. The most damaging failure here, and the quietest: results look plausible and are meaningless.

  • Deciding in both the server component and the client component. Hydration mismatch, plus two impressions per visitor.

  • A visitor id minted during render. The first pageview is always control.

  • Edge runtime for a decided route. The datafile has nowhere to live, so every request either fetches it or falls back to the off state.

  • revalidate added later by someone else. Leave a comment on the route saying why it is dynamic; this regression arrives months after the experiment ships.

David Sertillange, Independent experimentation specialist
David Sertillange

Independent experimentation specialist

David Sertillange is an independent experimentation specialist with 10 years implementing Optimizely across enterprise programs. He specializes in Feature Experimentation, analytics integrations, and helping teams build a culture of data-driven decision making.

Subscribe

Practical Optimizely tips, monthly. No fluff.