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.