~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:
- GitLab Feature Flags: https://docs.gitlab.com/ee/operations/feature_flags/
- Unleash for Next.js: https://docs.getunleash.io/feature-flag-tutorials/nextjs
TL;DR
- Server-only Unleash + session-aware flags in Next.js App Router
- Live demo: https://demo.sebastian.omg.lol/gitlab-feature-flags
- Code: https://gitlab.com/Sebkasanzew/gitlab-feature-flags
Table of contents
- Demo
- Overview
- Implementation
- Setup & Tooling
- Operate & Troubleshoot
- Run it locally
- Recap & Next steps
Demo
Try the demo here: https://demo.sebastian.omg.lol/gitlab-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
useFlag
— view
- Typed
-
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
- Helpers (including stable
Architecture at a glance
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-onlyinstanceId
- 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" },
})
}
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 })
}
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 []
}
}
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.
Diagram reference: steps 3 and 8 (poll for new toggles and update the UI).Show minimal code for FeatureFlagsLive.tsx
// 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>
)
}
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>
)}
</>
)
}
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.
Select “New feature flag” and create your flags (for this demo: new-header
and beta-banner
).
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
).
- API URL → set this as
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"
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
(withapi
scope) -
GITLAB_PROJECT_ID
(numeric; copy from your project settings)
How to run:
pnpm generate:unleash-types
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
: ensureappName
matchesUNLEASH_APP_NAME
, andinstanceId
equalsUNLEASH_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
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)