Back to blog

How to Implement Feature Flags Like LaunchDarkly Using the Nodora Rule Engine

This guide covers how to implement a flexible feature flag system similar to LaunchDarkly for controlling feature rollouts, A/B testing, and user segmentation.

Darko M.·

The thing flag services like LaunchDarkly figured out is that "should this user see this feature" is not really application code. It's a decision that changes constantly, gets edited by people who don't deploy, and needs to be the same answer on the server, the client, and the analytics pipeline. It belongs in one place that the rest of the app reads from.

You can build that on top of Nodora without much ceremony. A rule is a function from inputs to outputs, and "given this user, is the feature on" is exactly that shape. This post walks through common flag patterns and how to build them with Nodora.

The shared client

All the examples below use the same client, set up once:

// app/lib/nodora.ts
import { NodoraClient } from "@nodora/client";

export const nodora = new NodoraClient({
  apiKey: process.env.NODORA_API_KEY!,
  env: "production",
  strategy: { type: "stale-while-revalidate", ttl: 60_000 },
});

export const flags = nodora.ruleset("Flags");

stale-while-revalidate is what makes flags feel instant. You evaluate against the cached rule, and a background fetch picks up any new version. Flipping a flag in the dashboard reaches every server within the TTL, with no redeploy.

Pattern 1: a plain on-off switch

The simplest possible flag. One output, one boolean. You hardcode the value in the rule and flip it in the dashboard when you want to ship.

rule NewCheckout {
  out enabled = false
}

The JS side is just as boring:

const result = await flags.evaluate("NewCheckout", {});

if (result.outputs.enabled) {
  // show the new checkout
}

Flipping it doesn't need a deploy, and the same value is visible to every part of the app that asks. But it's also the least interesting case. The whole point of flags is that the answer depends on who's asking.

Pattern 2: user allowlist

The next step up. The flag is on, but only for a specific set of user IDs. This is what you reach for to test something with your own team before letting it loose.

rule InternalDebugPanel {
  allowed_users = [
    "usr_01H8X9...",
    "usr_01H8Y2...",
    "usr_01H8Z7..."
  ]
  out enabled = input.user_id in allowed_users
}

The rule does the membership check; the route just passes the user ID in:

const result = await flags.evaluate("InternalDebugPanel", {
  user_id: user.id,
});

When a new engineer joins and needs access, you add their ID to the rule. No PR, no deploy.

Pattern 3: targeting by plan or role

Allowlists scale to maybe a dozen users before they become a chore. The more durable version is to target by an attribute the user already has (plan, role, team, signup cohort). The pattern is the same shape, just more inputs.

rule AdvancedAnalytics {
  user = input.user
  is_internal = strings::ends_with(user.email, "@yourcompany.com")

  out enabled = user.plan in ["pro", "max", "enterprise"]
                || user.role == "admin"
                || is_internal
}

Pass the user through and let the rule decide:

const result = await flags.evaluate("AdvancedAnalytics", { user });

if (result.outputs.enabled) {
  // render the analytics tab
}

Notice that the rule is the only thing that knows what counts as "advanced analytics access." If sales later promises the feature to a specific account on the team plan, you add a forced_account_ids list and an extra || clause. The route doesn't change.

Pattern 4: percentage rollout

This is where flag systems earn their keep. You want to turn on new_search for 10% of users today, 50% tomorrow, 100% by Friday, and you want a given user to consistently land in the same bucket so they don't see the feature flicker on refresh.

The trick is to compute a stable bucket number (0 to 99) from the user ID and the flag key, then let the rule compare it to a threshold. Doing the hashing in JS keeps the rule readable and the hashing function easy to swap:

// app/lib/bucket.ts
import { createHash } from "node:crypto";

export function bucket(userId: string, flagKey: string): number {
  const hex = createHash("sha1").update(`${flagKey}:${userId}`).digest("hex");
  return parseInt(hex.slice(0, 8), 16) % 100;
}

Mixing the flag key into the hash means two different flags at 10% rollout don't pick the exact same 10% of users, which would skew any analytics you tried to do.

The rule is then a single comparison:

rule NewSearch {
  out rollout_percent = 10
  out enabled = input.bucket < rollout_percent
}

And the route hands the precomputed bucket in:

import { bucket } from "@/app/lib/bucket";

const result = await flags.evaluate("NewSearch", {
  bucket: bucket(user.id, "new_search"),
});

if (result.outputs.enabled) {
  // route to the new search backend
}

When you want to bump the rollout to 50%, you change 10 to 50 in the rule. Any user whose bucket is below 50 now sees the feature, and (because the hash is stable) every user who already had it still does.

Pattern 5: rollout with overrides

Real rollouts are rarely "pure percentage." You usually want internal users on at 100%, paying customers fast-tracked, and the percentage check only as the fallback. Combining the previous patterns:

rule NewSearch {
  out rollout_percent = 25

  forced_on  = input.user.id in ["usr_01H8X9...", "usr_01H8Y2..."]
  forced_off = input.user.id in ["usr_01HQA4..."]
  internal   = strings::ends_with(input.user.email, "@yourcompany.com")
  by_percent = input.bucket < rollout_percent

  out enabled = !forced_off && (forced_on || internal || by_percent)
}

Each clause is its own line so you can read the policy top to bottom: who's force-disabled (escape hatch for a customer who hit a bug), who's force-enabled (your team plus a beta tester), who's always in (internal), and who's in by virtue of landing in the rollout bucket. The route call barely changes:

const result = await flags.evaluate("NewSearch", {
  user,
  bucket: bucket(user.id, "new_search"),
});

When something goes wrong for a single customer, dropping their ID into forced_off is a one-line edit that propagates in a minute. That's the kind of thing you really do not want to be doing with a code deploy at 11pm.

Pattern 6: multivariate (A/B/C tests)

Sometimes the question isn't "on or off" but "which variant." Same machinery, different output type. The bucket becomes the input to a match and the output is the variant name:

rule CheckoutButtonExperiment {
  out variant = match input.bucket {
    b when b < 33  => "a",
    b when b < 66  => "b",
    _  => "c",
  }

  out is_control = variant == "a"
}

The route forwards the variant straight to the client, and the client renders whichever button it gets:

const result = await flags.evaluate("CheckoutButtonExperiment", {
  bucket: bucket(user.id, "checkout_button_experiment"),
});

return Response.json({ variant: result.outputs.variant });
// app/components/CheckoutButton.tsx
"use client";

export default function CheckoutButton({ variant }: { variant: "a" | "b" | "c" }) {
  if (variant === "b") return <button className="bg-emerald-600 ...">Buy now</button>;
  if (variant === "c") return <button className="bg-zinc-900 ...">Complete order</button>;
  return <button className="bg-blue-600 ...">Checkout</button>;
}

Crucially, the same variant value should be sent to your analytics so you can actually attribute conversions back to a bucket. Keeping that one string as the source of truth (rather than three booleans scattered across the app) is what makes the experiment analyzable later.

Pattern 7: scheduled flags

You want a feature to turn itself on at midnight UTC on launch day. No human needs to be awake. Push the timestamp into the rule and let it compare:

rule HolidayBanner {
  starts_at = time::parse_rfc3339("2026-07-01T00:00:00Z")
  ends_at   = time::parse_rfc3339("2026-07-14T00:00:00Z")
  now       = time::now()

  out enabled = now >= starts_at && now < ends_at
}

No input needed for the basic case, since the rule reads time::now() itself:

const result = await flags.evaluate("HolidayBanner", {});

if (result.outputs.enabled) {
  // render the banner
}

The banner appears and disappears on its own. Deciding to extend the promo by a day is a simple edit of the ends_at string in the rule.

Reading many flags at once

So far every example has been one rule, one route. In real apps a single page often needs several flag values at once. Rules can be evaluated together, returning a flat object the client can pick from:

// app/api/flags/route.ts

import { flags } from "@/app/lib/nodora";
import { bucket } from "@/app/lib/bucket";
import { getUser } from "@/app/lib/auth";

export async function GET(request: Request) {
  const user = await getUser(request);
  const ctx = { user };

  const [newSearch, advanced, experiment] = await Promise.all([
    flags.evaluate("NewSearch", { ...ctx, bucket: bucket(user.id, "new_search") }),
    flags.evaluate("AdvancedAnalytics", ctx),
    flags.evaluate("CheckoutButtonExperiment", {
      ...ctx,
      bucket: bucket(user.id, "checkout_button_experiment"),
    }),
  ]);

  return Response.json({
    new_search: newSearch.outputs.enabled,
    advanced_analytics: advanced.outputs.enabled,
    checkout_variant: experiment.outputs.variant,
  });
}

Because evaluations are local against the cached rules, the parallel fan-out is cheap. The client gets one response with everything it needs to render the page.

Why this ends up nicer than rolling your own

You can absolutely build a flag system out of a flags table in Postgres, a cache, and a switch statement. Plenty of teams do. What you get by putting the policy in a rule is the things you stop having to build:

  • The same answer everywhere. Server, client, and analytics all evaluate the same rule against the same inputs. No drift between a useFeatureFlag hook on the frontend and an if on the backend.
  • No deploys for flag changes. Flipping a flag, bumping a rollout percentage, or adding someone to an allowlist is a rule edit that propagates fast.
  • Targeting that's actually readable. A rule with five named clauses and an || is something anyone can review. A 40-line switch statement is not.
  • One place to remove dead flags. When you ship the new checkout for good, you delete the rule and grep for its name. The cleanup story is the part everyone forgets.

None of this is magic. It's just the same trick every flag service uses: policy out of the app, evaluated through a shared, accessible system.