Feature Advertisement: Backend-Driven Frontend Adaptation

Feature Advertisement: Backend-Driven Frontend Adaptation

Hardcoded feature checks scatter across your frontend. Environment differences cause confusion. Deployments mismatch. Every new feature requires frontend updates.

The feature advertisement pattern solves this: backend advertises capabilities, frontend adapts. No feature list maintained on frontend. No environment-specific code. Just ask: “Do you have X?”

Architecture#

Backend maintains a feature enum and registry. Configuration controls which features are enabled. Frontend fetches the list once at startup and checks dynamically.

┌─────────────┐  GET /api/features  ┌─────────────┐
│  Frontend   │────────────────────►│   Backend   │
│             │◄────────────────────│             │
│   Adapts    │   {features...}     │  Advertises │
└─────────────┘                     └─────────────┘

Backend: Define Features#

# features.py
from enum import Enum

class Feature(str, Enum):
    ADVANCED_SEARCH = "advanced_search"
    EXPORT_PDF = "export_pdf"
    BULK_OPERATIONS = "bulk_operations"
    REAL_TIME_NOTIFICATIONS = "real_time_notifications"

Single source of truth. Backend only.

Backend: Configuration#

# config.py
from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    enabled_features: set[str] = {
        "advanced_search",
        "export_pdf",
    }

    class Config:
        env_prefix = "APP_"

settings = Settings()

Control via environment:

APP_ENABLED_FEATURES="advanced_search,export_pdf"

Backend: Feature Registry#

# registry.py
from features import Feature
from config import settings

class FeatureRegistry:
    def __init__(self):
        self._features: dict[str, dict] = {}

    def register(self, feature: Feature, metadata: dict = None):
        if feature.value in settings.enabled_features:
            self._features[feature.value] = metadata or {}

    def get_all(self) -> dict:
        return self._features

registry = FeatureRegistry()

Backend: Register Features#

# main.py
from registry import registry
from features import Feature

# Register features with optional metadata
registry.register(Feature.ADVANCED_SEARCH, {
    "operators": ["AND", "OR", "NOT"]
})

registry.register(Feature.EXPORT_PDF, {
    "max_pages": 100
})

registry.register(Feature.BULK_OPERATIONS, {
    "max_items": 500
})

Only enabled features get registered. Disabled features never appear in the response.

Backend: API Endpoint#

# routes.py
from fastapi import APIRouter
from registry import registry

router = APIRouter()

@router.get("/api/features")
async def get_features():
    return registry.get_all()

Response:

{
  "advanced_search": {
    "operators": ["AND", "OR", "NOT"]
  },
  "export_pdf": {
    "max_pages": 100
  }
}

If it’s in the response, it’s enabled. Simple.

Frontend: Entire Implementation#

// features.ts
let features: Record<string, any> | null = null;

export async function loadFeatures(): Promise<void> {
  const res = await fetch("/api/features");
  features = await res.json();
}

export function hasFeature(name: string): boolean {
  return features !== null && name in features;
}

export function getFeature<T = any>(name: string): T | null {
  return features?.[name] ?? null;
}

That’s it. Fifteen lines.

Frontend: Initialize#

// main.tsx or App.tsx
import { loadFeatures } from "./features";

// Load at app startup
await loadFeatures();

// or in React
function App() {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    loadFeatures().then(() => setReady(true));
  }, []);

  if (!ready) return <Loading />;
  return <MainApp />;
}

Frontend: Usage#

import { hasFeature, getFeature } from "./features";

function Toolbar() {
  return (
    <div>
      {hasFeature("export_pdf") && <ExportButton />}
      {hasFeature("bulk_operations") && <BulkMenu />}
    </div>
  );
}

function ExportButton() {
  const config = getFeature("export_pdf");
  const maxPages = config?.max_pages ?? 50;

  return <button>Export (max {maxPages} pages)</button>;
}

No TypeScript types for feature names. No enum to maintain. Just strings. If backend has it, it works. Unknown features safely return false or null.

Adding New Features#

Backend only:

# features.py
class Feature(str, Enum):
    ...
    NEW_FEATURE = "new_feature"

# main.py
registry.register(Feature.NEW_FEATURE, {
    "some_config": "value"
})

Frontend uses it:

{hasFeature("new_feature") && <NewComponent />}

Done. No types, no enums, no context updates.

Environment-Based Features#

# config.py
class Settings(BaseSettings):
    environment: Literal["dev", "staging", "prod"] = "dev"

    @property
    def enabled_features(self) -> set[str]:
        base = {"advanced_search", "export_pdf"}
        if self.environment == "dev":
            return base | {"experimental_ai"}
        return base

Frontend doesn’t care about environments. It just asks hasFeature("experimental_ai"). Backend decides based on environment.

┌────────────────────┬─────┬─────────┬──────┐
│      Feature       │ DEV │ STAGING │ PROD │
├────────────────────┼─────┼─────────┼──────┤
│  advanced_search   │  ✓  │    ✓    │  ✓   │
│  export_pdf        │  ✓  │    ✓    │  ✓   │
│  experimental_ai   │  ✓  │    ✗    │  ✗   │
└────────────────────┴─────┴─────────┴──────┘

Frontend code is identical across all environments. hasFeature("experimental_ai") returns false in production automatically.

Optional: React Hook#

// hooks/useFeature.ts
import { hasFeature, getFeature } from "../features";

export function useFeature(name: string) {
  return {
    enabled: hasFeature(name),
    config: getFeature(name),
  };
}

Usage:

function SearchBar() {
  const { enabled, config } = useFeature("advanced_search");

  if (!enabled) return <BasicSearch />;
  return <AdvancedSearch operators={config.operators} />;
}

Optional: Gate Component#

// components/Feature.tsx
import { hasFeature } from "../features";

export function Feature({ name, children, fallback = null }) {
  return hasFeature(name) ? children : fallback;
}
// Usage
<Feature name="export_pdf">
  <ExportButton />
</Feature>

<Feature name="premium_analytics" fallback={<UpgradePrompt />}>
  <AnalyticsDashboard />
</Feature>

Testing#

Mock the module:

import * as features from "../features";

test("shows export when available", () => {
  vi.spyOn(features, "hasFeature").mockImplementation(
    (name) => name === "export_pdf"
  );

  render(<Toolbar />);
  expect(screen.getByText(/export/i)).toBeInTheDocument();
});

No MockFeatureProvider. No context wrapping. Just mock the function.

File Structure#

backend/
├── features.py      # Feature enum (source of truth)
├── config.py        # Which features are enabled
├── registry.py      # Registry class
└── routes.py        # GET /api/features

frontend/
├── features.ts      # ~15 lines total
└── components/
    └── Feature.tsx  # Optional helper component

Backend owns all feature knowledge. Frontend has 15 lines of feature code. That’s the pattern.