React 19 Lazy Component Loading for Feature Flags: Conditionally Shipping Code Only to Tenants Who Can Use It
Feature flags and code splitting are two separate problems most teams solve independently. Feature flags control access, code splitting controls payload. But here's the gap: you can have both—by lazy-loading React components only for tenants who can actually use them, you solve the bundle size problem and prevent accidental exposure of paid features to free users in a single move.
I've burned myself on this twice in CitizenApp. The first time, I shipped an entire AI feature module (Claude integration for document analysis) to free users even though the backend properly gated it. A clever user read the bundle, found the endpoint, and started making API calls. The second time, I had feature flags for access control but lazy-loaded everything, which meant free users were downloading React components they'd never render. React 19's lazy() paired with tenant feature flags fixes both problems elegantly.
The Problem: Feature Flags and Code Splitting Are Not the Same Thing
Let me be clear: feature flags are not code splitting.
A feature flag checks user.tier === 'pro' at runtime and conditionally renders a component. The component code still shipped in your bundle. You've prevented access but not payload bloat.
Code splitting with React.lazy() and Suspense delays loading a chunk until the component is mounted. But if you lazy-load everything, free users still request the chunk when they navigate to a feature page (404 or permission denied). You've added latency and network overhead for features they can't use.
The two-step dance is:
- Feature flag check determines if the tenant should have access
- Lazy loading ensures the component code only loads if the flag is true
If you skip step two, bundle bloat wins. If you skip step one, you've exposed code you shouldn't have.
The Solution: Lazy Components Gated by Tenant Flags
Here's the pattern I use in CitizenApp.
First, define your tenant's feature flags in a hook:
// hooks/useFeatureFlags.ts
import { useContext } from 'react';
import { TenantContext } from '../context/TenantContext';
export const useFeatureFlags = () => {
const { tenant } = useContext(TenantContext);
return {
hasDocumentAI: tenant?.tier === 'pro' || tenant?.tier === 'enterprise',
hasBulkAnalysis: tenant?.tier === 'enterprise',
hasWebhooks: tenant?.tier === 'pro' || tenant?.tier === 'enterprise',
hasCustomModels: tenant?.tier === 'enterprise',
};
};
Why in a hook and not a context value directly? Because I want to co-locate the flag logic with the check. When a PM asks "who can use webhooks?", I jump to one line.
Next, lazy-load your feature components:
// components/FeatureLoader.tsx
import { lazy, Suspense, ReactNode } from 'react';
import { useFeatureFlags } from '../hooks/useFeatureFlags';
// Only define these if the flag exists
const DocumentAI = lazy(() =>
import('./features/DocumentAI').then(mod => ({ default: mod.DocumentAI }))
);
const BulkAnalysis = lazy(() =>
import('./features/BulkAnalysis').then(mod => ({ default: mod.BulkAnalysis }))
);
interface FeatureLoaderProps {
feature: keyof ReturnType<typeof useFeatureFlags>;
fallback?: ReactNode;
children?: ReactNode;
}
export const FeatureLoader = ({
feature,
fallback = <div>Loading...</div>,
children
}: FeatureLoaderProps) => {
const flags = useFeatureFlags();
if (!flags[feature]) {
return null; // Don't render, don't request the chunk
}
const componentMap = {
hasDocumentAI: DocumentAI,
hasBulkAnalysis: BulkAnalysis,
hasWebhooks: () => <div>Webhooks feature</div>,
hasCustomModels: () => <div>Custom models feature</div>,
};
const Component = componentMap[feature];
if (!Component) {
console.warn(`Unknown feature: ${feature}`);
return null;
}
return (
<Suspense fallback={fallback}>
<Component />
</Suspense>
);
};
Now in your router or feature page:
// pages/dashboard.tsx
import { FeatureLoader } from '../components/FeatureLoader';
export default function Dashboard() {
return (
<div className="space-y-6">
<h1>Features</h1>
<FeatureLoader
feature="hasDocumentAI"
fallback={<div className="h-64 bg-gray-200 animate-pulse" />}
/>
<FeatureLoader feature="hasBulkAnalysis" />
<FeatureLoader feature="hasWebhooks" />
</div>
);
}
The magic: if tenant.tier === 'free', all flags are false. The component code is never requested. The chunk for DocumentAI.tsx never appears in the network tab. The browser never even knows it exists.
Backend Parity: Double-Check at the API Layer
This is critical: lazy-loading prevents accidental exposure, but it's not a security boundary. A motivated user can request the API endpoint directly.
Always validate the flag on the backend:
# FastAPI endpoint
from fastapi import HTTPException, Depends
from sqlalchemy.orm import Session
async def get_current_tenant(request: Request, db: Session = Depends(get_db)):
token = request.headers.get("Authorization", "").replace("Bearer ", "")
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
tenant = db.query(Tenant).filter(Tenant.id == payload["tenant_id"]).first()
if not tenant:
raise HTTPException(status_code=401)
return tenant
@app.post("/api/analyze-document")
async def analyze_document(
file: UploadFile,
tenant: Tenant = Depends(get_current_tenant),
db: Session = Depends(get_db)
):
# Check the flag server-side
if tenant.tier not in ["pro", "enterprise"]:
raise HTTPException(
status_code=403,
detail="Document AI requires Pro tier"
)
# Process with Claude
content = await file.read()
message = await client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "Analyze this document:"},
{"type": "document", "source": {
"type": "base64",
"media_type": "application/pdf",
"data": base64.b64encode(content).decode()
}}
]
}]
)
return {"analysis": message.content[0].text}
The frontend lazy-load prevents bundle bloat and accidental exposure. The backend check prevents exploitation.
Gotcha: Lazy Components and TypeScript
This one bit me hard. When you lazy-load a component, TypeScript doesn't automatically infer its props:
// ❌ This won't work
const DocumentAI = lazy(() => import('./features/DocumentAI'));
// DocumentAI accepts props, but TS doesn't know that
// ✅ Do this instead
const DocumentAI = lazy(() =>
import('./features/DocumentAI').then(mod => ({ default: mod.DocumentAI }))
);
Or define your lazy components with explicit typing:
// components/LazyFeatures.ts
import { lazy, ComponentType } from 'react';
interface DocumentAIProps {
tenantId: string;
}
export const DocumentAI = lazy(
() => import('./features/DocumentAI')
) as ComponentType<DocumentAIProps>;
Bundle Impact in Production
On CitizenApp, this pattern reduced the main bundle by 34KB (gzipped). For free users, the Document AI, Bulk Analysis, and Webhook modules never load—three separate chunks skipped entirely. The lazy-loaded chunks only materialize for pro users who navigate to the feature.
What I Missed the First Time
I tried to be clever and dynamically map feature names to components using a registry. Maintenance nightmare. Now I hardcode the component imports in FeatureLoader—boring but explicit. When you're debugging bundle issues or feature exposure, clarity wins every time.
Also, don't overthink tenant context. A simple object with tier and plan ID is enough. I used to store a massive feature capabilities object that required syncing with frontend code. Single source of truth is simpler: compute flags from tier.
The two-step dance—feature flags for access control, lazy loading for payload reduction—is the right way to ship tiered SaaS. Do both or do neither; doing just one leaves you exposed or bloated.
Top comments (0)