7 min read

Type‑Safe Feature Toggles in Next.js: Harnessing LaunchDarkly with TypeScript

Learn how to generate compile‑time flag typings from LaunchDarkly and use them safely across pages, API routes, and edge functions in a Next.js app.
Type‑Safe Feature Toggles in Next.js: Harnessing LaunchDarkly with TypeScript

Introduction

Feature toggles (a.k.a. feature flags) let you ship code — and decide at runtime whether a user sees the new behavior. The power comes from decoupling deployment from release, enabling canary rollouts, A/B tests, and instant rollbacks.

When you add TypeScript to the mix, you gain compile‑time guarantees that you’re not referencing a flag that doesn’t exist or using the wrong flag type. In this article we’ll walk through a practical, end‑to‑end setup:

  1. Define flags in LaunchDarkly
  2. Export a JSON schema that describes every flag (key, type, default)
  3. Generate TypeScript types from that schema using a tiny code‑gen script
  4. Consume the typed flags in Next.js pages, API routes, and edge functions
  5. Test flag‑driven code without contacting LaunchDarkly

By the end you’ll have a reusable library that makes feature toggles feel like first‑class, type‑safe citizens of your codebase.


1. Setting Up LaunchDarkly

Create a LaunchDarkly project and environment (e.g., production). Add a few flags that we’ll use throughout the demo:

Flag key Type Default value
new-dashboard Boolean false
beta-search Boolean false
max-items-per-page Number 20
welcome-message String "Welcome!"
Tip – Keep flag keys snake‑case; they translate nicely to TypeScript camelCase later.

LaunchDarkly can export a JSON representation of all flags via its REST API:

curl -H "Authorization: api-${LD_API_KEY}" \
     "https://app.launchdarkly.com/api/v2/flags/${PROJECT_KEY}?environmentKey=${ENV_KEY}" \
     -o flags.json

The resulting flags.json contains each flag’s key, variations, and default. We’ll use this file as the single source of truth for our type generation.


2. Generating Type‑Safe Flag Types

2.1 The code‑gen script

Create scripts/generate-ld-types.ts:

import { writeFileSync, readFileSync } from "fs";
import { resolve } from "path";

type LDFlag = {
  key: string;
  variations: Array<{ value: unknown }>;
  defaults?: { variation: number };
};

type LDExport = {
  items: LDFlag[];
};

const FLAGS_JSON = resolve(__dirname, "../flags.json");
const OUT_TS = resolve(__dirname, "../src/featureFlags.ts");

// Load the raw export
const raw: LDExport = JSON.parse(readFileSync(FLAGS_JSON, "utf-8"));

// Helper to infer the primitive type from the first variation
const inferType = (flag: LDFlag): string => {
  const sample = flag.variations[0].value;
  const t = typeof sample;
  if (t === "boolean") return "boolean";
  if (t === "number") return "number";
  if (t === "string") return "string";
  // Fallback for complex JSON objects – treat as unknown
  return "unknown";
};

// Build a union of flag keys
const flagKeys = raw.items.map(f => `'${f.key}'`).join(" | ");

// Build an interface mapping each key to its inferred type
const flagMap = raw.items
  .map(f => `  '${f.key}': ${inferType(f)};`)
  .join("\n");

// Emit the file
const content = `/* AUTO‑GENERATED – DO NOT EDIT */
export type FlagKey = ${flagKeys};

export interface FlagValues {
${flagMap}
}

/**
 * Helper that narrows a raw flag value to the correct type.
 * Use only after the SDK has resolved the flag.
 */
export function getTypedFlag<K extends FlagKey>(
  key: K,
  value: unknown
): FlagValues[K] {
  return value as FlagValues[K];
}
`;

writeFileSync(OUT_TS, content);
console.log("✅ Generated src/featureFlags.ts");

Run it with ts-node scripts/generate-ld-types.ts. The script produces src/featureFlags.ts:

/* AUTO‑GENERATED – DO NOT EDIT */
export type FlagKey = 'new-dashboard' | 'beta-search' | 'max-items-per-page' | 'welcome-message';

export interface FlagValues {
  'new-dashboard': boolean;
  'beta-search': boolean;
  'max-items-per-page': number;
  'welcome-message': string;
}

/**
 * Helper that narrows a raw flag value to the correct type.
 * Use only after the SDK has resolved the flag.
 */
export function getTypedFlag<K extends FlagKey>(key: K, value: unknown): FlagValues[K] {
  return value as FlagValues[K];
}

Now every flag key is a literal type, and each key maps to its exact primitive type. If you later add a flag or change its type, a single npm run gen:flags will update the typings and cause a compile‑time error wherever the old shape is used.

2.2 Adding the script to package.json

{
  "scripts": {
    "gen:flags": "ts-node scripts/generate-ld-types.ts",
    "dev": "next dev",
    "build": "next build"
  }
}

Run npm run gen:flags whenever you pull a fresh flags.json from LaunchDarkly.


3. Integrating the LaunchDarkly SDK

Install the official client:

npm i launchdarkly-node-server-sdk launchdarkly-react-client-sdk

We’ll create a singleton wrapper that returns a typed flag map.

3.1 Server‑side provider (src/lib/ldServer.ts)

import { LDClient, init } from "launchdarkly-node-server-sdk";
import { FlagKey, FlagValues, getTypedFlag } from "./featureFlags";

let client: LDClient | null = null;

export async function getLDClient(): Promise<LDClient> {
  if (client) return client;
  client = init(process.env.LD_SDK_KEY!, {
    // Optional: set a low timeout for dev environments
    timeout: 5000,
  });
  await client.waitForInitialization();
  return client;
}

/**
 * Resolve a flag for a given user and return a correctly typed value.
 */
export async function getFlag<K extends FlagKey>(
  key: K,
  user: LDUser
): Promise<FlagValues[K]> {
  const ld = await getLDClient();
  const raw = await ld.variation(key, user, undefined);
  // The SDK guarantees the type matches the flag definition,
  // but we still cast via our helper for TypeScript.
  return getTypedFlag(key, raw);
}

3.2 Client‑side hook (src/lib/ldClient.tsx)

import { useEffect, useState } from "react";
import { LDClient, initialize } from "launchdarkly-react-client-sdk";
import { FlagKey, FlagValues, getTypedFlag } from "./featureFlags";

let ldClient: LDClient | null = null;

export function useFlag<K extends FlagKey>(key: K, defaultValue: FlagValues[K]) {
  const [value, setValue] = useState<FlagValues[K]>(defaultValue);

  useEffect(() => {
    if (!ldClient) {
      ldClient = initialize(process.env.NEXT_PUBLIC_LD_CLIENT_ID!, {
        // The user object can be enriched with email, key, etc.
        key: "anonymous",
        anonymous: true,
      });
    }

    const handler = (newVal: unknown) => {
      setValue(getTypedFlag(key, newVal));
    };

    // Subscribe to changes – useful for live flag updates
    ldClient?.on(`change:${key}`, handler);

    // Initial fetch
    ldClient?.variation(key, defaultValue, (val) => handler(val));

    return () => {
      ldClient?.off(`change:${key}`, handler);
    };
  }, [key, defaultValue]);

  return value;
}
Why the helper? The LaunchDarkly SDK returns any at runtime. By feeding the raw value through getTypedFlag, we keep the compiler happy and still get the safety of our generated FlagValues map.

4. Using Typed Flags in Next.js

4.1 Page‑level example

// pages/dashboard.tsx
import { GetServerSideProps } from "next";
import { getFlag } from "../src/lib/ldServer";
import { FlagValues } from "../src/featureFlags";

type Props = {
  showNewDashboard: FlagValues["new-dashboard"];
};

export const getServerSideProps: GetServerSideProps<Props> = async (ctx) => {
  const user = { key: ctx.req.cookies["userId"] ?? "guest" };
  const showNewDashboard = await getFlag("new-dashboard", user);
  return { props: { showNewDashboard } };
};

export default function Dashboard({ showNewDashboard }: Props) {
  return (
    <>
      {showNewDashboard ? (
        <h1>🚀 New Dashboard</h1>
      ) : (
        <h1>Classic Dashboard</h1>
      )}
    </>
  );
}

Because showNewDashboard is typed as boolean, any accidental misuse (e.g., treating it as a string) will be caught at compile time.

4.2 API route example

// pages/api/search.ts
import type { NextApiRequest, NextApiResponse } from "next";
import { getFlag } from "../../src/lib/ldServer";
import { FlagValues } from "../../src/featureFlags";

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const user = { key: req.headers["x-user-id"] as string ?? "guest" };
  const useBeta = await getFlag("beta-search", user);

  const results = useBeta
    ? await fetchBetaResults(req.query.q as string)
    : await fetchLegacyResults(req.query.q as string);

  res.status(200).json(results);
}

Again, useBeta is guaranteed to be a boolean. If you later change the flag to a string (e.g., to hold a variant name), the compiler will highlight every place that expects a boolean.

4.3 Edge function (Vercel)

Edge functions run on the CDN and must use the client‑side SDK (no Node.js SDK). The same useFlag hook works, but for static edge routes we can pre‑fetch flags at request time:

// middleware.ts (Edge)
import { NextResponse } from "next/server";
import { initialize } from "launchdarkly-react-client-sdk";
import { getTypedFlag, FlagKey, FlagValues } from "./src/featureFlags";

const ld = initialize(process.env.NEXT_PUBLIC_LD_CLIENT_ID!, {
  key: "edge-anonymous",
  anonymous: true,
});

export async function middleware(request: Request) {
  const url = new URL(request.url);
  const flagKey: FlagKey = "welcome-message";

  const raw = await ld.variation(flagKey, "Welcome!", (val) => val);
  const welcome = getTypedFlag(flagKey, raw) as FlagValues["welcome-message"];

  const response = NextResponse.next();
  response.headers.set("x-welcome-message", welcome);
  return response;
}

The edge middleware now injects a custom header that downstream pages can read, all while preserving type safety.


5. Testing Flag‑Driven Logic

Running integration tests against the real LaunchDarkly service is slow and flaky. Instead, mock the SDK and rely on the generated types.

// __mocks__/launchdarkly-node-server-sdk.ts
export const init = jest.fn(() => ({
  waitForInitialization: jest.fn().mockResolvedValue(undefined),
  variation: jest.fn((key: any, _user: any, _default: any) => {
    const mockValues: Record<string, unknown> = {
      "new-dashboard": true,
      "beta-search": false,
      "max-items-per-page": 50,
      "welcome-message": "Hello Test",
    };
    return Promise.resolve(mockValues[key]);
  }),
}));

Now a test for the dashboard page:

import { render, screen } from "@testing-library/react";
import Dashboard, { getServerSideProps } from "../pages/dashboard";

jest.mock("launchdarkly-node-server-sdk");

test("renders new dashboard when flag is true", async () => {
  const ctx = { req: { cookies: { userId: "test" } } } as any;
  const { props } = await getServerSideProps(ctx);
  render(<Dashboard {...props} />);
  expect(screen.getByText(/New Dashboard/)).toBeInTheDocument();
});

If you rename new-dashboard to new-dashboard-v2 in LaunchDarkly, the generated FlagKey union will change, causing the test (and the page) to fail at compile time—exactly the safety net we wanted.


6. Best Practices & Gotchas

✅ Practice ⚠️ Pitfall
Generate types on every CI run – keep the contract in sync with LaunchDarkly. Relying on defaults only – if a flag is missing in the JSON export, the generated type will be incomplete.
Scope flags per environment – include the environment name in the key (e.g., prod_new-dashboard) to avoid accidental cross‑env leakage. Using any in custom SDK wrappers – always funnel values through getTypedFlag.
Version‑control flags.json – treat it like any other schema file. Changing a flag’s primitive type – this will break all consumers; bump a major version of your client library.
Prefer server‑side evaluation for SEO‑critical UI – SSR can decide which markup to render before the browser sees it. Fetching flags in every request – cache the client instance; the SDK already handles background polling.
Leverage LaunchDarkly’s targeting rules – keep business logic out of code, inside the dashboard. Hard‑coding user keys – use real identifiers (email, UUID) to get accurate rollout percentages.

7. Putting It All Together

  1. Export flagsflags.json (CI step).
  2. Run npm run gen:flagssrc/featureFlags.ts.
  3. Import FlagKey / FlagValues wherever you need a flag.
  4. Use getFlag (SSR/API) or useFlag (client/edge) to retrieve a typed value.
  5. Write tests that mock the SDK; the compiler will alert you to any mismatched flag usage.

With this pipeline, feature toggles become a type‑checked contract between product, ops, and engineering. You gain the agility of continuous delivery without sacrificing the safety net that TypeScript provides.


Conclusion

Feature toggles are a powerful delivery mechanism, but they can become a source of bugs when flag names drift or types change unnoticed. By exporting LaunchDarkly’s flag definitions, generating a strict TypeScript map, and wrapping the SDK in tiny helper functions, you get:

  • Zero‑runtime surprises – the compiler guarantees you’re using the right type.
  • Fast feedback – a missing or renamed flag fails the build before it reaches production.
  • Reusable code – the same getFlag/useFlag helpers work across pages, API routes, and edge middleware.

Give this pattern a try in your next Next.js project. Once the generation step is baked into your CI, you’ll wonder how you ever lived without type‑safe feature toggles.