DEV Community

Cover image for Using GitLab Feature Flags with Next.js
Sebastian Kasanzew
Sebastian Kasanzew

Posted on

Using GitLab Feature Flags with Next.js

~9 min read

This post shows how to wire up GitLab Feature Flags in a Next.js App Router app with a few practical goals:

  • Keep Unleash secrets on the server (never in the browser)
  • Cache smartly to keep cold starts fast and upstream traffic low
  • Stay type‑safe end‑to‑end with Zod validation and generated flag names

Official docs:

TL;DR

Table of contents

Demo

Try the demo here: https://demo.sebastian.omg.lol/gitlab-feature-flags

Live demo page with two example feature flags

This interactive page demonstrates two sample flags — new-header and beta-banner — controlled via GitLab Feature Flags. Flags are evaluated server‑side and bootstrapped to the client. The Unleash SDK then polls every 10 seconds to update the UI. For where each step happens, see the architecture diagram below in Architecture at a glance.

You can find the full source code in this repo: https://gitlab.com/Sebkasanzew/gitlab-feature-flags

How to use it:

  • Use the “GitLab Feature Flags” panel at the bottom to toggle flags. Changes are saved in GitLab and reflected here after the next poll countdown.
  • Watch the 2 banners at the top, or the "Active Flags" in the "Debug Information" box as you enable/disable them.

Overview

We’ll set up a server‑only proxy to the GitLab feature flag instance that supports the Unleash SDK, with a simple frontend bootstrap.

Key files we’ll look at:

  • src/app/api/unleash-definitions/route.ts
    • Server proxy to Unleash features with caching and fallback — view
  • src/app/api/features/route.ts
    • Evaluates features per session and returns toggles to the client SDK — view
  • src/app/page.tsx
    • Server component that bootstraps the client SDK with validated toggles — view
  • src/app/FeatureFlagsLive.tsx
    • Client wiring for the Unleash SDK — view
  • src/app/components/FeatureFlagsLiveContent.tsx
    • Presentational UI that consumes flags via typed hooks — view
  • src/app/components/typed-unleash-hooks.ts
    • Typed useFlagview
  • scripts/generate-unleash-types.ts
    • Generate a union of flag names from GitLab — view
  • src/lib/schemas.ts
    • Zod schemas for validating Unleash definitions — view
  • src/lib/utils.ts
    • Helpers (including stable getOrGenerateSessionId) — view

Architecture at a glance

Architecture diagram showing server-only proxy, session-aware evaluation, and client polling

The diagram maps the full request/response flow end‑to‑end:

1) Bootstrap flags (server) — The page (React Server Component) derives a stable sessionId, builds an absolute URL to /api/features, and fetches initial toggles.
2) First paint — The page renders immediately with the toggles, avoiding flashes of incorrect content.
3) Client polling — The Unleash client polls your own /api/features every 10 seconds to keep the UI fresh.
4) Internal fetch (server) — /api/features calls the internal proxy /api/unleash-definitions to retrieve raw Unleash definitions (no session context yet).
5) Upstream fetch — The proxy talks to GitLab’s Unleash backend using the server‑only Instance ID and returns validated, cacheable definitions.
6) Session‑aware evaluation — /api/features evaluates the definitions for exactly one sessionId.
7) Minimal response — It returns only { toggles: IToggle[] } to the browser (no secrets, no full definitions).
8) Live updates — The client applies new toggles and the UI updates without a reload.

Server first, client light:

  • Server-only proxy talks to GitLab’s Unleash endpoint. Secrets never leave the server.
  • A second server endpoint evaluates flags per session and returns only the toggles the browser needs.
  • The page (RSC) bootstraps the client with validated toggles to avoid flashes.
  • The client SDK polls every 10 seconds to keep the UI in sync.

This gives you: secure secrets, thin client, fast cold start, and a clean mental model.

Implementation

Tiny server-only proxy: api/unleash-definitions

What it does:

The proxy strictly validates appName and the server-only instanceId so arbitrary clients can’t scrape your definitions. It then fetches Unleash definitions from GitLab with a short revalidation window to keep upstream traffic low, returns cache-friendly responses, and maintains an in-memory fallback to ride out brief upstream hiccups.

Why it matters:

Your Unleash Instance ID never leaves the server, and the rest of your app can rely on a single, stable source of truth for feature definitions.

Contract:

  • Input: GET with validated appName and server-only instanceId
  • Output: JSON Unleash definitions; cacheable response
  • Caching: short s-maxage with stale-while-revalidate; in-memory fallback
  • Errors: serves last good response on brief upstream issues

Diagram reference: steps 4→5 (the proxy request and the upstream response).

Show minimal code for /api/unleash-definitions
// src/app/api/unleash-definitions/route.ts
import { getDefinitions } from "@unleash/nextjs"
import type { NextRequest } from "next/server"
import { env } from "@/env"

export const dynamic = "force-dynamic"

export async function GET(request: NextRequest) {
    // ... validate query params if needed
    const response = await getDefinitions({
        url: `${env.UNLEASH_SERVER_API_URL}/client/features`,
        appName: env.UNLEASH_APP_NAME,
        instanceId: env.UNLEASH_SERVER_INSTANCE_ID,
        // ... fetch options/caching
    })

    return new Response(JSON.stringify(response), {
        headers: { "content-type": "application/json" },
    })
}
Enter fullscreen mode Exit fullscreen mode

Session-aware evaluation: api/features

What it does:

This route calls the internal proxy, validates the JSON payload, and evaluates toggles for exactly one session. Stickiness is derived from a numeric sessionId (provided or generated). The response is trimmed to what the browser needs—{ toggles: IToggle[] }—with cache headers tuned for quick revalidations.

Why it matters:

Full definitions never reach the browser, and each user sees consistent variants because the session ID remains stable.

Contract:

  • Input: GET; derives/sticks to sessionId; calls internal definitions proxy
  • Output: { toggles: IToggle[] } only (no full definitions leak)
  • Caching: no-store
  • Errors: returns empty array or last known safe state on failure

Diagram reference: steps 4, 6, and 7 (call proxy, evaluate per session, return toggles).

Show minimal code for /api/features
// src/app/api/features/route.ts
import { evaluateFlags } from "@unleash/nextjs"
import { type NextRequest, NextResponse } from "next/server"
import { env } from "@/env"

export const dynamic = "force-dynamic"

export async function GET(req: NextRequest) {
    const url = new URL(req.url)
    const internal = new URL("/api/unleash-definitions", url.origin)
    internal.searchParams.set("appName", env.UNLEASH_APP_NAME)
    internal.searchParams.set("instanceId", env.UNLEASH_SERVER_INSTANCE_ID)

    const res = await fetch(internal, { cache: "no-store" })
    const defs = await res.json()

    const sessionId = "..." // ... get or generate stable session id
    const { toggles } = evaluateFlags(defs, { sessionId })

    return NextResponse.json({ toggles })
}
Enter fullscreen mode Exit fullscreen mode

Bootstrap in RSC: app/page.tsx

What it does:

In this (RSC) React Server Component, the page reads the session cookie (or creates a new numeric sessionId), constructs an absolute URL to /api/features using forwarded headers, and fetches initial toggles. Those toggles are then passed to the client as initialToggles.

Why it matters:

The very first paint already knows which features are on or off. No flash of incorrect content and no intermediate “loading flags…” screen.

Minimal code (handles basePath deployments and forwards appName):

Diagram reference: steps 1→2 (bootstrap on the server and render the first paint with toggles).

Show minimal code for app/page.tsx
// src/app/page.tsx
import type { IToggle } from "@unleash/nextjs/client"
import { cookies, headers } from "next/headers"
import { env } from "@/env"
import { UNLEASH_CONFIG } from "@/lib/constants"
import { fetchBootstrapToggles } from "@/lib/fetchToggles"
import { getOrGenerateSessionId } from "@/lib/utils"
import { FeatureFlagsLive } from "./FeatureFlagsLive"

export const dynamic = "force-dynamic"

export default async function Home() {
    const cookieStore = await cookies()
    const existingSessionId = cookieStore.get(UNLEASH_CONFIG.COOKIE_NAME)?.value
    const sessionId = getOrGenerateSessionId(existingSessionId)

    const initialToggles = await getInitialToggles()

    return (
        <FeatureFlagsLive
            sessionId={sessionId}
            pollingIntervalMs={10_000}
            initialToggles={initialToggles}
        />
    )
}

async function getInitialToggles(): Promise<IToggle[]> {
    const basePath = env.NEXT_PUBLIC_BASE_PATH ?? ""

    const h = await headers()
    const host = h.get("x-forwarded-host") ?? h.get("host") ?? "localhost:3000"
    const proto = h.get("x-forwarded-proto") ?? "http"
    const origin = `${proto}://${host}`

    const apiUrl = new URL(`${basePath}/api/features`, origin)
    const appName = env.UNLEASH_APP_NAME
    apiUrl.searchParams.set("appName", appName)

    try {
        return await fetchBootstrapToggles(apiUrl.toString())
    } catch {
        return []
    }
}
Enter fullscreen mode Exit fullscreen mode

Minimal client wiring: FeatureFlagsLive.tsx

What it does:

The client sets up a FlagProvider that talks to your own /api/features endpoint. It hydrates from server‑provided bootstrap data on first render and then polls periodically, so changes in GitLab are shown in the UI without a reload.

Why it matters:

You get instant, correct evaluations and seamless live updates, while the browser never touches secrets.

Show minimal code for FeatureFlagsLive.tsx

Diagram reference: steps 3 and 8 (poll for new toggles and update the UI).

// src/app/FeatureFlagsLive.tsx
"use client"

import { FlagProvider, type IToggle } from "@unleash/nextjs/client"

type Props = {
    sessionId: string
    initialToggles?: IToggle[]
    pollingIntervalMs?: number
    appName: string // pass from server: env.UNLEASH_APP_NAME
    // ... other display props
}

export function FeatureFlagsLive(props: Props) {
    const { sessionId, appName, initialToggles = [], pollingIntervalMs = 10_000 } = props

    return (
        <FlagProvider
            config={{
                url: "/api/features", // ... include basePath if used
                clientKey: "demo-local-frontend",
                appName,
                context: { sessionId },
                bootstrap: initialToggles,
                refreshInterval: Math.floor(pollingIntervalMs / 1000),
                disableMetrics: true,
            }}
        >
            {/* ... your UI using hooks like useFlag(...) */}
        </FlagProvider>
    )
}
Enter fullscreen mode Exit fullscreen mode

Live content UI: FeatureFlagsLiveContent.tsx

What it does:

This component is the small, presentational layer that reads flags using the typed useFlag hook and renders UI accordingly. It shows a card describing the “New Header” flag and conditionally renders a warning banner when the beta-banner flag is enabled.

Why it matters:

It cleanly separates SDK wiring (provider, polling, bootstrap) from UI consumption. That keeps your client surface tiny and easy to test: flags go in via hooks, UI reflects them directly.

Show minimal code for FeatureFlagsLiveContent.tsx
// src/app/components/FeatureFlagsLiveContent.tsx
"use client"

import { useFlag } from "./typed-unleash-hooks"
import { FeatureFlagCard } from "./FeatureFlagCard"
import { WarningIcon } from "./icons"
import {
    Banner,
    BannerContent,
    BannerDescription,
    BannerIcon,
    BannerTitle,
} from "./ui"

export function FeatureFlagsLiveContent() {
    const isNewHeaderEnabled = useFlag("new-header")
    const isBetaBannerEnabled = useFlag("beta-banner")

    return (
        <>
            <FeatureFlagCard
                title="New Header"
                enabled={isNewHeaderEnabled}
                description={
                    isNewHeaderEnabled
                        ? "The new header design is currently enabled for your session."
                        : "The new header design is disabled. You're seeing the default header."
                }
            />

            {isBetaBannerEnabled && (
                <Banner variant="warning">
                    <BannerIcon variant="warning">
                        <WarningIcon />
                    </BannerIcon>
                    <BannerContent variant="warning">
                        <BannerTitle variant="warning">Beta Program Active</BannerTitle>
                        <BannerDescription variant="warning">
                            Welcome early adopters! You're using our beta features.
                        </BannerDescription>
                    </BannerContent>
                </Banner>
            )}
        </>
    )
}
Enter fullscreen mode Exit fullscreen mode

Type-safe flag checks: typed-unleash-hooks.ts

What it does:

This thin wrapper around the SDK’s useFlag only accepts flag names that exist, using an UnleashFeatureName union for safety.

Why it matters:

No more new-heaeder‑style typos—your editor and type system catch them before runtime.

Setup & Tooling

Set up feature flags in GitLab (and get API URL + Instance ID)

Here’s a quick, UI-driven setup to create your flags and find the two values this app needs: API URL (UNLEASH_SERVER_API_URL) and Instance ID (UNLEASH_SERVER_INSTANCE_ID).

1) Create feature flags in your project

In your GitLab project, go to: Deploy > Feature flags.

GitLab feature flags list screen

Select “New feature flag” and create your flags (for this demo: new-header and beta-banner).

GitLab new feature flag creation form

Choose a strategy (for a simple start, “All users” in “All environments” is fine). You can refine later with Percent Rollout, User IDs, or User Lists.

2) Get access credentials (API URL + Instance ID)

  • On the same page (Deploy > Feature flags), select “Configure”.
  • Copy these values:
    • API URL → set this as UNLEASH_SERVER_API_URL
    • Instance ID → set this as UNLEASH_SERVER_INSTANCE_ID
    • Application name → optional here; if you use environment-specific strategies, set UNLEASH_APP_NAME to match (for example: production, staging).

GitLab Configure panel showing API URL and Instance ID

Notes

  • The API URL typically looks like: https://gitlab.com/api/v4/feature_flags/unleash/<project-id> (the UI shows the exact URL; no need to construct it).
  • Instance ID is a token GitLab uses to authorize fetching definitions. In this demo, keep it server-only—never expose it to the browser.

Security notes

Important: Never expose the Unleash Instance ID (server token) to the browser. The unleash-session-id cookie contains no secrets; it only ensures sticky evaluations per session.

3) Put values into your local env

Create .env.local (not committed) with:

UNLEASH_SERVER_API_URL="<paste from Configure>"
UNLEASH_SERVER_INSTANCE_ID="<paste from Configure>"
# Optional but recommended for clarity in strategies/environments
UNLEASH_APP_NAME="production"
Enter fullscreen mode Exit fullscreen mode

That’s it — the server-only routes in this repo will use these to fetch definitions and evaluate toggles safely.

Generate flag names from GitLab: generate-unleash-types.ts

This script calls the GitLab REST API to list your feature flags and writes src/types/unleash-flags.generated.ts, exposing both a convenient FEATURE_FLAGS object for imports and a UnleashFeatureName union type for compile‑time safety.

Environment variables required:

  • GITLAB_TOKEN (with api scope)
  • GITLAB_PROJECT_ID (numeric; copy from your project settings)

How to run:

pnpm generate:unleash-types
Enter fullscreen mode Exit fullscreen mode

Remember: whenever you add/rename/delete feature flags in GitLab, re-run this script and commit the updated src/types/unleash-flags.generated.ts. This keeps your useFlag(...) calls type-safe across the team and CI.

Operate & Troubleshoot

Troubleshooting

  • 403 from /api/unleash-definitions: ensure appName matches UNLEASH_APP_NAME, and instanceId equals UNLEASH_SERVER_INSTANCE_ID from GitLab “Configure”.
  • Flags don’t update: verify the app points to the correct GitLab Unleash API URL.

Performance & caching notes

  • The internal definitions route uses a short revalidate (~2s) to protect upstream and returns Cache-Control: public, s-maxage=2, stale-while-revalidate=59.
  • The public, session-aware /api/features endpoint is intentionally non-cacheable to ensure each poll sees fresh state (Cache-Control: no-store, Vary: Cookie).

Run it locally

Environment variables

Name Required Purpose
UNLEASH_SERVER_API_URL Yes GitLab Unleash API base (from Configure panel)
UNLEASH_SERVER_INSTANCE_ID Yes Server token; keep server-only
UNLEASH_APP_NAME No For strategies/attribution

Prerequisites

  • A GitLab project with Feature Flags enabled and two demo flags: new-header, beta-banner
  • Node.js 18.18+
  • pnpm

Steps

pnpm install
pnpm dev
pnpm generate:unleash-types # optionally generate types from your GitLab project
Enter fullscreen mode Exit fullscreen mode

Recap & Next steps

This setup keeps secrets on the server, evaluates flags per session, and hydrates the client with a minimal, validated payload. It’s fast by default, type-safe by design, and easy to reason about.

Clone it, plug in your GitLab Feature Flags, and ship changes behind flags with confidence.

Next steps you can explore:

  • Add percentage rollouts
  • Target by user ID or groups
  • Write tests with mocked definitions (MSW)
  • Add monitoring/alerts for Unleash API downtime

Top comments (0)