Tutorial December 11, 2025 10 min read

Feature Flags in Frontend Applications: The Right Way

You have a backend using feature flags. Now you need those flags in your frontend. Should you call the API directly from the browser? Use an SDK? The answer depends on your architecture, but there is a clear best practice.

TL;DR

  • SSR apps (Next.js, Nuxt): Evaluate flags server-side, pass to client
  • SPAs (Vue, React): Backend sends flags with initial data, frontend caches them
  • Never: Expose SDK keys to the browser or evaluate flags client-side with sensitive context

Why Not Just Use the SDK in the Browser?

It is tempting to use a JavaScript SDK directly in the browser. Initialize it on page load, check flags wherever you need them. Simple, right?

There are problems with this approach:

Security Risk

Your SDK key is exposed in the browser. Anyone can extract it and query your flag service directly. Even "read-only" keys reveal your flag names, targeting rules, and rollout percentages.

Performance Hit

The SDK needs to fetch flags before your app can render. That is an extra network round-trip on every page load, adding 50-200ms of latency.

Flash of Wrong Content

If flags load after initial render, users see the default state, then the UI jumps when flags arrive. This is jarring and unprofessional.

Sensitive Context Exposure

Flag evaluation often needs user context (plan type, company, internal user). Sending this to a third-party service from the browser may violate privacy policies.

The Right Architecture

The solution is simple: evaluate flags on the server, send results to the client.

Browser
Receives flag values
Your Backend
Evaluates flags
Flagit
Flag service

Your backend talks to Flagit (with the SDK key safely on the server). The browser only receives the evaluated boolean/string values - no SDK key, no targeting rules, no sensitive context.

Pattern 1: SSR Applications (Next.js, Nuxt)

Server-side rendered apps have a huge advantage: you can evaluate flags during the render and include them in the initial HTML. Zero flash, zero extra requests.

Next.js Example

// lib/flagit.ts
import { FlagitClient } from '@flagit/node';

// Singleton - reuse across requests
let client: FlagitClient | null = null;

export function getFlagitClient() {
  if (!client) {
    client = new FlagitClient({
      baseUrl: process.env.FLAGIT_URL!,
      apiKey: process.env.FLAGIT_API_KEY!,
      environment: process.env.FLAGIT_ENV || 'production',
    });
  }
  return client;
}
// app/dashboard/page.tsx (App Router)
import { getFlagitClient } from '@/lib/flagit';
import { getServerSession } from 'next-auth';

export default async function DashboardPage() {
  const session = await getServerSession();
  const flagit = getFlagitClient();

  // Evaluate flags server-side with user context
  const flags = {
    showAnalytics: await flagit.isEnabled('dashboard-analytics', {
      userId: session?.user?.id,
      plan: session?.user?.plan,
    }),
    newNavigation: await flagit.isEnabled('new-navigation', {
      userId: session?.user?.id,
    }),
    betaFeatures: await flagit.isEnabled('beta-features', {
      userId: session?.user?.id,
      email: session?.user?.email,
    }),
  };

  return (
    <FeatureFlagProvider flags={flags}>
      <Dashboard />
    </FeatureFlagProvider>
  );
}
// components/FeatureFlagProvider.tsx
'use client';

import { createContext, useContext, ReactNode } from 'react';

type Flags = Record<string, boolean>;

const FlagContext = createContext<Flags>({});

export function FeatureFlagProvider({
  flags,
  children
}: {
  flags: Flags;
  children: ReactNode;
}) {
  return (
    <FlagContext.Provider value={flags}>
      {children}
    </FlagContext.Provider>
  );
}

export function useFlag(flagName: string): boolean {
  const flags = useContext(FlagContext);
  return flags[flagName] ?? false;
}

// Usage in any client component:
// const showAnalytics = useFlag('showAnalytics');

Why this works

  • Flags are evaluated once during SSR
  • Results are embedded in the HTML - no flash
  • Client components use simple context - no SDK needed
  • SDK key never leaves the server

Caching in Next.js

The Flagit SDK caches flags locally and receives real-time updates via SSE. But in serverless environments, you might want additional caching:

// lib/flagit.ts - with caching for serverless
import { unstable_cache } from 'next/cache';
import { FlagitClient } from '@flagit/node';

const client = new FlagitClient({
  baseUrl: process.env.FLAGIT_URL!,
  apiKey: process.env.FLAGIT_API_KEY!,
});

// Cache flag results for 60 seconds
export const getFlags = unstable_cache(
  async (userId: string, plan: string) => {
    const context = { userId, plan };
    return {
      showAnalytics: await client.isEnabled('dashboard-analytics', context),
      newNavigation: await client.isEnabled('new-navigation', context),
      betaFeatures: await client.isEnabled('beta-features', context),
    };
  },
  ['feature-flags'],
  { revalidate: 60 } // Revalidate every 60 seconds
);

Pattern 2: SPA with API Backend (Vue, React)

For single-page applications that fetch data from a separate API, include flags in your initial data payload.

Backend: Include Flags in API Response

// .NET API - UserController.cs
[HttpGet("me")]
public async Task<IActionResult> GetCurrentUser()
{
    var user = await _userService.GetCurrentUserAsync(User);

    // Evaluate flags server-side
    var context = new Dictionary<string, object>
    {
        ["userId"] = user.Id,
        ["plan"] = user.Subscription.Plan,
        ["company"] = user.Company?.Name ?? ""
    };

    var flags = new
    {
        showAnalytics = await _flagit.IsEnabledAsync("dashboard-analytics", context),
        newNavigation = await _flagit.IsEnabledAsync("new-navigation", context),
        betaFeatures = await _flagit.IsEnabledAsync("beta-features", context),
        advancedExports = await _flagit.IsEnabledAsync("advanced-exports", context),
    };

    return Ok(new
    {
        user.Id,
        user.Email,
        user.Name,
        Subscription = user.Subscription,
        FeatureFlags = flags  // Include in response
    });
}

Vue 3 Frontend

// composables/useFeatureFlags.ts
import { ref, readonly } from 'vue';

interface FeatureFlags {
  showAnalytics: boolean;
  newNavigation: boolean;
  betaFeatures: boolean;
  advancedExports: boolean;
}

const flags = ref<FeatureFlags>({
  showAnalytics: false,
  newNavigation: false,
  betaFeatures: false,
  advancedExports: false,
});

const loaded = ref(false);

export function useFeatureFlags() {
  // Set flags from API response (call once on app init)
  function setFlags(newFlags: FeatureFlags) {
    flags.value = newFlags;
    loaded.value = true;
  }

  // Check individual flag
  function isEnabled(flag: keyof FeatureFlags): boolean {
    return flags.value[flag] ?? false;
  }

  return {
    flags: readonly(flags),
    loaded: readonly(loaded),
    setFlags,
    isEnabled,
  };
}
// App.vue or main entry point
<script setup lang="ts">
import { onMounted } from 'vue';
import { useFeatureFlags } from '@/composables/useFeatureFlags';
import { useUserStore } from '@/stores/user';

const { setFlags } = useFeatureFlags();
const userStore = useUserStore();

onMounted(async () => {
  // Fetch user data (includes flags)
  const response = await fetch('/api/me');
  const data = await response.json();

  userStore.setUser(data);
  setFlags(data.featureFlags); // Hydrate flags from API response
});
</script>
// components/Dashboard.vue
<script setup lang="ts">
import { useFeatureFlags } from '@/composables/useFeatureFlags';

const { isEnabled } = useFeatureFlags();
</script>

<template>
  <div class="dashboard">
    <NavigationNew v-if="isEnabled('newNavigation')" />
    <NavigationLegacy v-else />

    <main>
      <AnalyticsWidget v-if="isEnabled('showAnalytics')" />

      <section class="exports">
        <ExportAdvanced v-if="isEnabled('advancedExports')" />
        <ExportBasic v-else />
      </section>
    </main>
  </div>
</template>

React Frontend

// contexts/FeatureFlagContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

interface FeatureFlags {
  showAnalytics: boolean;
  newNavigation: boolean;
  betaFeatures: boolean;
  advancedExports: boolean;
}

interface FlagContextValue {
  flags: FeatureFlags;
  setFlags: (flags: FeatureFlags) => void;
  isEnabled: (flag: keyof FeatureFlags) => boolean;
}

const defaultFlags: FeatureFlags = {
  showAnalytics: false,
  newNavigation: false,
  betaFeatures: false,
  advancedExports: false,
};

const FlagContext = createContext<FlagContextValue | null>(null);

export function FeatureFlagProvider({ children }: { children: ReactNode }) {
  const [flags, setFlags] = useState<FeatureFlags>(defaultFlags);

  const isEnabled = (flag: keyof FeatureFlags) => flags[flag] ?? false;

  return (
    <FlagContext.Provider value={{ flags, setFlags, isEnabled }}>
      {children}
    </FlagContext.Provider>
  );
}

export function useFeatureFlags() {
  const context = useContext(FlagContext);
  if (!context) {
    throw new Error('useFeatureFlags must be used within FeatureFlagProvider');
  }
  return context;
}

// Convenience hook for single flag
export function useFlag(flag: keyof FeatureFlags): boolean {
  const { isEnabled } = useFeatureFlags();
  return isEnabled(flag);
}
// App.tsx
import { useEffect } from 'react';
import { FeatureFlagProvider, useFeatureFlags } from './contexts/FeatureFlagContext';

function AppContent() {
  const { setFlags } = useFeatureFlags();

  useEffect(() => {
    fetch('/api/me')
      .then(res => res.json())
      .then(data => {
        setFlags(data.featureFlags);
      });
  }, []);

  return <Dashboard />;
}

export default function App() {
  return (
    <FeatureFlagProvider>
      <AppContent />
    </FeatureFlagProvider>
  );
}
// components/Dashboard.tsx
import { useFlag } from '../contexts/FeatureFlagContext';

export function Dashboard() {
  const showAnalytics = useFlag('showAnalytics');
  const newNavigation = useFlag('newNavigation');

  return (
    <div className="dashboard">
      {newNavigation ? <NavigationNew /> : <NavigationLegacy />}

      <main>
        {showAnalytics && <AnalyticsWidget />}
      </main>
    </div>
  );
}

Pattern 3: Real-Time Flag Updates

Sometimes you need flags to update without a page refresh. For example, you want to enable a feature for a user while they are actively using the app.

Backend SSE Proxy

Create an endpoint that proxies flag updates to the frontend. This keeps the SDK key on the server while allowing real-time updates.

// .NET API - FlagUpdatesController.cs
[HttpGet("flag-updates")]
public async Task StreamFlagUpdates(CancellationToken cancellationToken)
{
    Response.ContentType = "text/event-stream";
    Response.Headers.Add("Cache-Control", "no-cache");
    Response.Headers.Add("Connection", "keep-alive");

    var user = await _userService.GetCurrentUserAsync(User);
    var context = new { userId = user.Id, plan = user.Subscription.Plan };

    // Subscribe to flag changes from Flagit
    await foreach (var update in _flagit.StreamUpdatesAsync(cancellationToken))
    {
        // Re-evaluate affected flags for this user
        var flags = new
        {
            showAnalytics = await _flagit.IsEnabledAsync("dashboard-analytics", context),
            newNavigation = await _flagit.IsEnabledAsync("new-navigation", context),
            // ... other flags
        };

        var json = JsonSerializer.Serialize(flags);
        await Response.WriteAsync($"data: {json}\n\n", cancellationToken);
        await Response.Body.FlushAsync(cancellationToken);
    }
}
// Vue composable with SSE
export function useFeatureFlagsWithUpdates() {
  const { flags, setFlags, isEnabled } = useFeatureFlags();

  function connectToUpdates() {
    const eventSource = new EventSource('/api/flag-updates');

    eventSource.onmessage = (event) => {
      const newFlags = JSON.parse(event.data);
      setFlags(newFlags);
    };

    eventSource.onerror = () => {
      // Reconnect after 5 seconds
      eventSource.close();
      setTimeout(connectToUpdates, 5000);
    };

    return () => eventSource.close();
  }

  return { flags, isEnabled, connectToUpdates };
}

Note: Real-time updates add complexity. Only use this pattern if you genuinely need flags to change during a session. For most apps, evaluating flags on page load is sufficient.

Performance Tips

Batch flag evaluation

Evaluate all flags you need in one call, not scattered throughout the request. The SDK caches flags locally, so this is fast.

Include flags in existing API calls

Do not create a separate /api/flags endpoint. Include flags in your /api/me or initial data endpoint to avoid extra round trips.

Use typed flag objects

Define your flags as a TypeScript interface. This catches typos at compile time and makes refactoring easier.

Default to safe values

If flags have not loaded yet, default to false or the legacy behavior. Never show a loading spinner waiting for flags.

Common Questions

What about feature flags for A/B testing?

Same pattern. Evaluate the variant server-side and pass it to the client. If you need to track which variant a user saw, log it on the backend where you have full context.

Can I use localStorage to cache flags?

Yes, but be careful. Stale flags can cause inconsistent experiences. If you cache, include a timestamp and refresh flags if they are older than a few minutes.

What if my SPA has no backend?

Consider adding a lightweight backend (even a serverless function) to evaluate flags. If that is not possible, you can use a client-side SDK with a "browser-safe" API key that only allows flag evaluation, but accept the security and performance trade-offs.

Should I hide UI elements or show disabled states?

For features in development, hide them completely. For features that are disabled for a user's plan, consider showing a disabled state with an upgrade prompt. This depends on your product strategy.

Summary

  • Always evaluate flags server-side - SDK keys stay secure
  • SSR apps: Evaluate during render, embed in HTML
  • SPAs: Include flags in your initial API response
  • Use context/composables for clean flag access in components
  • Type your flags to catch errors at compile time
  • Default to false until flags are loaded