Feature Flags in Next.js: The Complete Guide

Supa DeveloperSupa Developer
··5 min read

Next.js has become the default choice for React applications, and with the App Router it introduced a fundamental split that changes how you should think about feature flags: server vs. client.

Get this split wrong and you'll ship flags that flicker, leak to the wrong users, or silently fall back to the wrong value. Get it right and you get safe, zero-flicker rollouts across your entire stack.

This guide covers the full picture - from a simple client-side toggle to Edge Middleware that evaluates flags before a single byte of your app loads - using the Supaship Next.js SDK.

Installation

pnpm add @supashiphq/react-sdk @supashiphq/javascript-sdk

The @supashiphq/react-sdk is used for Client Components. The @supashiphq/javascript-sdk handles Server Components, layouts, and Edge Middleware. See the full Next.js SDK reference for all configuration options.

Add your API key to .env.local:

NEXT_PUBLIC_SUPASHIP_API_KEY=your-api-key-here

Why Next.js needs a different approach

In a traditional SPA every flag evaluation happens in the browser. In Next.js, code runs in at least three places:

WhereExamplesSDK to use
Server (Node.js)page.tsx, layout.tsx, Server Actions@supashiphq/javascript-sdk
Edgemiddleware.ts@supashiphq/javascript-sdk
Client (browser)Client Components ('use client')@supashiphq/react-sdk

If you evaluate a flag on the server but re-evaluate it differently on the client after hydration, you get a flash of the wrong content. The patterns below avoid that.

Pattern 1: Static environment flags

The simplest flag is an environment variable. No SDK, no network request, zero latency.

// lib/flags.ts
export const flags = {
  newCheckout: process.env.NEXT_PUBLIC_FLAG_NEW_CHECKOUT === 'true',
  darkMode: process.env.NEXT_PUBLIC_FLAG_DARK_MODE === 'true',
}
// app/checkout/page.tsx
import { flags } from '@/lib/flags'

export default function CheckoutPage() {
  return flags.newCheckout ? <NewCheckout /> : <LegacyCheckout />
}

When to use it: kill switches, per-environment features (staging vs. production), or flags that only need to change at deploy time.

Limitation: changing a flag requires a redeploy. For anything more dynamic you need a real flag service.

Pattern 2: Client Components with useFeature

For interactive UI, use SupaProvider in your root layout and the useFeature hook in any Client Component.

Step 1: Define your flags and set up the provider

// app/providers.tsx
'use client'
import {
  SupaProvider,
  FeaturesWithFallbacks,
  InferFeatures,
} from '@supashiphq/react-sdk'

const FEATURE_FLAGS = {
  'new-dashboard': false,
  'show-banner': false,
  'beta-features': [] as string[],
} satisfies FeaturesWithFallbacks

// Required for full TypeScript type safety
declare module '@supashiphq/react-sdk' {
  interface Features extends InferFeatures<typeof FEATURE_FLAGS> {}
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SupaProvider
      config={{
        apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
        environment: process.env.NODE_ENV!,
        features: FEATURE_FLAGS,
      }}>
      {children}
    </SupaProvider>
  )
}
// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang='en'>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Step 2: Use the hook in a Client Component

// app/dashboard/Dashboard.tsx
'use client'
import { useFeature } from '@supashiphq/react-sdk'

export function Dashboard() {
  const { feature: newDashboard, isLoading } = useFeature('new-dashboard')

  if (isLoading) return <LoadingSpinner />

  return newDashboard ? <NewDashboard /> : <LegacyDashboard />
}

Batching multiple flags

When you need several flags in one component, use useFeatures to make a single API call instead of one per flag:

'use client'
import { useFeatures } from '@supashiphq/react-sdk'

export function AppShell() {
  const { features, isLoading } = useFeatures([
    'new-dashboard',
    'show-banner',
    'beta-features',
  ])

  if (isLoading) return <LoadingSpinner />

  return (
    <div className={features['new-dashboard'] ? 'new-layout' : 'old-layout'}>
      {features['show-banner'] && <Banner />}
      <MainContent />
    </div>
  )
}

Passing user context for targeting

To target flags by user, pass context to the provider. Update it reactively when the user changes:

// app/providers.tsx
'use client'
import { useEffect } from 'react'
import { SupaProvider, useFeatureContext } from '@supashiphq/react-sdk'
import { useAuth } from '@/hooks/useAuth'

function UserContextSync() {
  const { updateContext } = useFeatureContext()
  const { user } = useAuth()

  useEffect(() => {
    if (user) {
      updateContext({
        userId: user.id,
        email: user.email,
        plan: user.plan,
      })
    }
  }, [user, updateContext])

  return null
}

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <SupaProvider
      config={{
        apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
        environment: process.env.NODE_ENV!,
        features: FEATURE_FLAGS,
      }}>
      <UserContextSync />
      {children}
    </SupaProvider>
  )
}

Pattern 3: Server Components (App Router)

Server Components are the best place to evaluate flags that gate page-level content. The evaluation happens on the server, the correct HTML is sent to the browser, and the client never sees an alternative - no flicker.

Use @supashiphq/javascript-sdk (the framework-agnostic client) directly in Server Components:

// app/page.tsx  (Server Component)
import { createClient } from '@supashiphq/javascript-sdk'
import { cookies } from 'next/headers'

const client = createClient({
  apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
  environment: process.env.NODE_ENV!,
})

export default async function HomePage() {
  const userId = (await cookies()).get('user_id')?.value

  // Fetch multiple flags in one call
  const features = await client.getFeatures(['new-hero', 'show-banner'], {
    context: { userId },
  })

  return (
    <main>
      {features['show-banner'] && <Banner />}
      {features['new-hero'] ? <NewHeroSection /> : <OldHeroSection />}
    </main>
  )
}

Because this runs in a Server Component, the flag values never reach the client as variables - the client receives only the rendered HTML. Hydration matches perfectly.

Caching server-side flags

By default, fetch in Next.js Server Components is cached. Use unstable_cache to control revalidation:

// lib/features.ts
import { unstable_cache } from 'next/cache'
import { createClient } from '@supashiphq/javascript-sdk'

const client = createClient({
  apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
  environment: process.env.NODE_ENV!,
})

export const getCachedFeatures = unstable_cache(
  async (userId: string) => {
    return await client.getFeatures(['feature-a', 'feature-b'], {
      context: { userId },
    })
  },
  ['features'],
  { revalidate: 30 }, // Re-fetch every 30 seconds
)

Pick your revalidation window based on how quickly flag changes need to propagate. revalidate: 30 is a good default - fast enough to feel instant to users, but not hammering the API on every request.

Pattern 4: Layouts for consistent flag context

If multiple pages need the same flag, evaluate it once in a shared layout:

// app/(marketing)/layout.tsx
import { createClient } from '@supashiphq/javascript-sdk'

const client = createClient({
  apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
  environment: process.env.NODE_ENV!,
})

export default async function MarketingLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const features = await client.getFeatures([
    'new-marketing-navbar',
    'show-holiday-banner',
  ])

  return (
    <>
      {features['new-marketing-navbar'] ? <NewNavbar /> : <LegacyNavbar />}
      {features['show-holiday-banner'] && <HolidayBanner />}
      {children}
    </>
  )
}

The flags are fetched once per layout render and all child pages share the result.

Pattern 5: Passing server flags to Client Components

Sometimes you need a flag value inside a Client Component - for interactive UI or browser APIs. Evaluate the flag in a Server Component and pass it down as a prop. This avoids double evaluation and prevents flicker.

// app/features/editor/page.tsx  (Server Component)
import { createClient } from '@supashiphq/javascript-sdk'
import { EditorToolbar } from './EditorToolbar'

const client = createClient({
  apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
  environment: process.env.NODE_ENV!,
})

export default async function EditorPage() {
  const features = await client.getFeatures(['rich-text-editor'])

  return <EditorToolbar richTextEnabled={features['rich-text-editor']} />
}
// app/features/editor/EditorToolbar.tsx
'use client'

interface Props {
  richTextEnabled: boolean
}

export function EditorToolbar({ richTextEnabled }: Props) {
  return (
    <div className='toolbar'>
      {richTextEnabled ? <RichTextControls /> : <BasicControls />}
    </div>
  )
}

The flag is evaluated exactly once (server-side), serialized as a prop, and the client hydrates cleanly with no visible change.

Pattern 6: Edge Middleware for route-level control

Middleware runs at the edge - before your app even loads. This makes it the right place for gating entire routes, such as beta access or early-access features.

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createClient } from '@supashiphq/javascript-sdk'

const client = createClient({
  apiKey: process.env.NEXT_PUBLIC_SUPASHIP_API_KEY!,
  environment: process.env.NODE_ENV!,
})

export async function middleware(request: NextRequest) {
  const userId = request.cookies.get('userId')?.value
  const email = request.cookies.get('email')?.value

  const betaAccess = await client.getFeature('beta-access', {
    context: {
      userId,
      email,
      path: request.nextUrl.pathname,
    },
  })

  if (!betaAccess) {
    return NextResponse.redirect(new URL('/404', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: '/beta/:path*',
}

You can also use middleware to rewrite requests - sending users to a different page without changing the URL:

// middleware.ts  (rewrite example)
export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl
  if (!pathname.startsWith('/checkout')) return NextResponse.next()

  const userId = request.cookies.get('user_id')?.value ?? 'anonymous'
  const newCheckout = await client.getFeature('new-checkout', {
    context: { userId },
  })

  if (newCheckout) {
    // User sees /checkout but gets the new implementation
    return NextResponse.rewrite(new URL('/checkout-v2', request.url))
  }

  return NextResponse.next()
}

No client-side redirect flash, no layout shift.

Avoiding the hydration mismatch trap

The most common mistake with Next.js feature flags is evaluating a flag differently on server and client:

// ❌ BAD  -  causes hydration mismatch
'use client'

export function FeatureWidget() {
  const [enabled, setEnabled] = useState(false)

  useEffect(() => {
    // Fires after hydration  -  user sees a flash
    fetchFlag('my-feature').then(setEnabled)
  }, [])

  return enabled ? <NewWidget /> : <OldWidget />
}

On the server (during SSR) enabled is false, so <OldWidget /> is rendered into HTML. After hydration the useEffect fires, the flag resolves to true, and React swaps to <NewWidget />. The user sees a flash.

The fix: evaluate the flag server-side and pass it as a prop (Pattern 5 above). When you genuinely need client-side evaluation, use useFeature from @supashiphq/react-sdk which handles the loading state cleanly:

// ✅ GOOD  -  loading state prevents flash
'use client'
import { useFeature } from '@supashiphq/react-sdk'

export function FeatureWidget() {
  const { feature: enabled, isLoading } = useFeature('my-feature')

  // Render nothing (or a skeleton) until the flag resolves
  if (isLoading) return <WidgetSkeleton />

  return enabled ? <NewWidget /> : <OldWidget />
}

Testing with mocked flags

Wrap components in a test SupaProvider with hardcoded feature values - no real API calls needed:

// test-utils/providers.tsx
import { SupaProvider, FeaturesWithFallbacks } from '@supashiphq/react-sdk'

export function TestProviders({
  children,
  features = {},
}: {
  children: React.ReactNode
  features?: FeaturesWithFallbacks
}) {
  return (
    <SupaProvider
      config={{
        apiKey: 'test-key',
        environment: 'test',
        features: { ...features } satisfies FeaturesWithFallbacks,
      }}>
      {children}
    </SupaProvider>
  )
}
// __tests__/HomePage.test.tsx
import { render, screen } from '@testing-library/react'
import { TestProviders } from '../test-utils/providers'
import HomePage from '../app/page'

describe('HomePage', () => {
  it('shows new hero when flag is enabled', () => {
    render(
      <TestProviders features={{ 'new-hero': true }}>
        <HomePage />
      </TestProviders>,
    )
    expect(screen.getByText('New Hero')).toBeInTheDocument()
  })

  it('shows old hero when flag is disabled', () => {
    render(
      <TestProviders features={{ 'new-hero': false }}>
        <HomePage />
      </TestProviders>,
    )
    expect(screen.getByText('Old Hero')).toBeInTheDocument()
  })
})

Putting it all together

Here's a mental model for where to evaluate flags in a Next.js app:

Request arrives
       │
       ▼
┌─────────────────────┐
│  Edge Middleware     │  ← Route gates, rewrites  (@supashiphq/javascript-sdk)
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  Server Components   │  ← Page/layout flags      (@supashiphq/javascript-sdk)
│  (layout / page)     │
└──────────┬──────────┘
           │ props
           ▼
┌─────────────────────┐
│  Client Components   │  ← Interactive UI         (@supashiphq/react-sdk)
└─────────────────────┘

Evaluate as high up as possible (edge > server > client). Each step down increases the risk of a visible flash or a hydration mismatch.

Getting started with Supaship

All of the patterns above use Supaship: percentage rollouts, user targeting, and a dashboard to change flags instantly - no redeploy required. See the full Next.js SDK reference and the targeting docs for the complete API.

Ready to ship with confidence? Start for free and have your first Next.js flag live in minutes. Free forever up to 1M events/month. Pro plan is $30/month for your entire workspace — unlimited projects, unlimited team members.


Related framework guides: Feature Flags in React · Feature Flags in Vue · Feature Flags in Node.js

Comparing platforms? Supaship vs LaunchDarkly · Supaship vs Statsig · Supaship vs ConfigCat · Best Feature Flag Platforms 2026


Feedback

Got thoughts on this?

We're constantly learning how developers actually use these tools. Ideas, use cases, integration requests — every bit of feedback makes the platform better for everyone.

Thanks for being part of the journey — Supaship