UI Patterns

Frontend Feature Flags

URL-based feature flags for internal and B2B SPAs, plus multi-deploy previews by git SHA.

Frontend feature flags can be as simple as query params.

For internal tools and B2B SaaS, this is often enough:

  • No backend flag service
  • No rollout control plane
  • Easy local debugging with a URL

For public consumer websites, this approach is usually not appropriate.

Security model

This pattern is security through obscurity.

  • Good fit: internal products and B2B apps where exposed flags are acceptable.
  • Bad fit: public apps where hidden features must stay secret.

If a user can view source and URL params, they can discover flags. Use backend authorization and server-side checks for anything security-sensitive.

URL flag helper (extracted pattern)

The snippet below shows a minimal URL-flag helper pattern used in production SPAs.

type FlagValue = string | number | boolean;
const searchParams = new URLSearchParams(window.location.search);
const flagsRegistry: Record<string, FlagValue> = {};

const parseBoolean = (value: string): boolean | undefined => {
  if (value === "true" || value === "1") return true;
  if (value === "false" || value === "0") return false;
  return undefined;
};

const urlFlag = <T extends FlagValue>(flag: string, defaultValue: T): T => {
  const raw = searchParams.get(flag);
  if (raw === null) return defaultValue;

  if (typeof defaultValue === "boolean") {
    const parsed = parseBoolean(raw);
    if (parsed === undefined) return defaultValue;
    flagsRegistry[flag] = parsed;
    return parsed as T;
  }

  if (typeof defaultValue === "number") {
    const parsed = Number.parseInt(raw, 10);
    if (Number.isNaN(parsed)) return defaultValue;
    flagsRegistry[flag] = parsed;
    return parsed as T;
  }

  flagsRegistry[flag] = raw;
  return raw as T;
};

export const urlWithFlags = (url: string) => {
  const out = new URL(url, window.location.origin);

  for (const [key, value] of Object.entries(flagsRegistry)) {
    out.searchParams.set(key, String(value));
  }

  // Return a relative URL for internal app navigation.
  return `${out.pathname}${out.search}${out.hash}`;
};

urlFlag("commit_sha", "");

// supported flags
export const flag1 = urlFlag("flag", Boolean(false));

Usage:

if (flag1) {
  // render feature-gated route or nav item
}

const nextUrl = urlWithFlags("/acme/users");
// keeps active flags while navigating

Why this pairs well with multi-deploy

When you keep many static builds available (for example on S3), you can switch both build version and feature toggles directly in the URL: ?commit_sha=<sha> selects a deployed build.

Example:

https://app.example.com/?commit_sha=13afff2

This is very useful for QA and product review because the URL is fully shareable and reproducible.

You can expose preview URLs directly from CI by setting a commit status with target_url that includes the commit SHA as a query parameter.

- name: Set preview status
  uses: actions/github-script@v4
  with:
    github-token: ${{ secrets.GITHUB_TOKEN }}
    script: |
      await github.repos.createCommitStatus({
        ...context.repo,
        sha: context.sha,
        state: "success",
        target_url: `https://my.example.com/?commit_sha=${context.sha}`,
        description: "Preview is ready!",
      });

With this, every PR gets a clickable environment URL that matches the exact build. Combined with URL flags, reviewers can verify hidden UI states without new backend deployments.

Practical guidelines

  • Keep the flag list small and explicit.
  • Use booleans for simple gates, strings for IDs.
  • Always define a safe default.
  • Propagate active flags in internal links (like urlWithFlags).
  • Never trust frontend flags for authorization.

On this page