Chapter 28
Enterprise Concerns
Enterprise React SPA concerns — OIDC / BFF auth patterns, multi-tenancy with per-tenant theming and feature flags, i18n at scale, and accessibility compliance pipelines.
Published 2026-05-23
🎯 Chapter Goal — After this chapter you can extend the Ch 5 auth example with enterprise-grade patterns (OIDC, BFF, silent refresh, multi-tenant identity providers), wire per-tenant theming and feature flags, ship i18n that lazy-loads locales, and bake accessibility compliance into the CI pipeline.
🧭 Prerequisites — Ch 5 (the basic auth flow this chapter extends). Ch 11 (component library — for theming). Ch 19 (monorepo).
🔹 28.1 Auth/SSO patterns — extending the Ch 5 example
The Ch 5 auth example uses a fake /api/login. Real enterprise auth is OIDC — OpenID Connect — typically via an identity provider (Auth0, Okta, Azure AD, Google Workspace, Keycloak).
OIDC in an SPA — the BFF pattern
The 2026 consensus: SPAs should not talk to identity providers directly. The “implicit flow” pattern (PKCE-only, tokens in JS) has known security limitations. The recommended pattern is the Backend-for-Frontend (BFF):
┌─────────┐ ┌───────┐ ┌───────────┐
│ Browser │ ── /api/login ────▶ │ BFF │ ── OIDC ───▶ │ IdP │
│ (SPA) │ ◀── set-cookie ──── │ │ ◀── tokens ── │ (Okta…) │
└─────────┘ └───────┘ └───────────┘
▲ │
└──── /api/* (cookie) ───────────┘
The BFF:
- Handles the OIDC dance (redirects, state param, PKCE).
- Exchanges the IdP code for tokens server-side.
- Sets an
httpOnlycookie on the browser containing a session ID. - Proxies
/api/*calls to the real backend, attaching the access token server-side.
The SPA never sees the access token. XSS can’t exfiltrate what JS can’t read. The session lives in an httpOnly cookie the browser sends automatically.
Wiring it — extending Ch 5
📄 replace src/auth/api.ts from the Ch 5 example
// New api.ts — talks to the BFF, not a fake function.
export type User = { id: string; email: string; name: string };
export const startLogin = (returnTo: string) => {
window.location.href = `/api/auth/login?return=${encodeURIComponent(returnTo)}`;
// The BFF redirects to the IdP; on success, IdP redirects back to /api/auth/callback,
// which sets the session cookie and 302s the user to `returnTo`.
};
export const fetchMe = async (): Promise<User | null> => {
const res = await fetch('/api/me', { credentials: 'include' });
if (res.status === 401) return null;
return res.json();
};
export const logout = async (): Promise<void> => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
};
The AuthProvider from Ch 5 stays mostly the same. The login() call now redirects to the BFF; fetchMe() checks current session state.
Silent token refresh
Access tokens expire (typical: 15 minutes). The session cookie holds a refresh token; the BFF refreshes the access token transparently.
The SPA’s job: handle 401s by re-trying once after a brief delay, in case the BFF needs to refresh:
// utils/fetchWithRetry.ts
export const fetchAuthed = async (url: string, init?: RequestInit): Promise<Response> => {
let res = await fetch(url, { ...init, credentials: 'include' });
if (res.status === 401) {
// Trigger BFF refresh:
await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include' });
res = await fetch(url, { ...init, credentials: 'include' });
}
return res;
};
The cleaner long-term pattern: the BFF refreshes inline on every request that lands with an expired token, returning the new cookie + the original response. The SPA needs no retry logic.
Multi-tenant identity providers
Enterprise customers often want SAML / OIDC from their IdP. Pattern:
- BFF reads the tenant from the URL (
acme.example/tenant-a/login) or a cookie. - Looks up the tenant’s IdP config (issuer URL, client ID, etc.).
- Initiates OIDC against that issuer.
- Same callback handler completes the dance regardless of which tenant.
Libraries: oidc-client-ts server-side; panva/jose for token verification; framework-level options (NextAuth, Auth.js) if your BFF is in Next.
SSO callback handling — the state parameter
The state parameter is your CSRF defence:
- Before redirect, BFF generates a random
statevalue, stores it in the user’s session (cookie). - Redirects to IdP with
state=.... - IdP redirects back to
/api/auth/callback?code=...&state=.... - BFF verifies the returned
statematches the stored one. Mismatch → reject.
Without this check, a malicious link can drop a user into your callback with a different user’s code. Always verify.
🔹 28.2 Multi-tenancy — per-tenant theming and feature flags
Per-tenant theming
If your design system uses CSS variables (Ch 8.5), per-tenant theming is one extra stylesheet:
// app boot:
const tenant = await fetchTenantConfig(window.location.hostname);
document.documentElement.style.setProperty('--color-primary', tenant.brandColor);
For richer themes (different fonts, layouts), generate a tenant-specific CSS bundle at deploy time and load it per tenant.
Feature flags
The 2026 stack: OpenFeature (https://openfeature.dev) as the spec, with a provider (GrowthBook, LaunchDarkly, ConfigCat, Unleash, Flagsmith).
import { OpenFeature, OpenFeatureProvider, useFlag } from '@openfeature/react-sdk';
// Boot:
OpenFeature.setProvider(new GrowthBookProvider({ /* … */ }));
// In a component:
const { value: showNewCheckout } = useFlag('new-checkout-flow', false);
if (showNewCheckout) return <NewCheckout />;
return <LegacyCheckout />;
The provider handles polling, caching, user targeting (per tenant, per user, per cohort). The component just reads a value.
⚠️ Don’t conflate feature flags with config. Flags are short-lived rollout controls; config is permanent. Both need governance — old flags accumulate and become tech debt.
🔹 28.3 i18n at scale — lazy-loaded locales, ICU messages
The library: react-intl or i18next (both mature in 2026).
Lazy-loaded locales — don’t ship every translation in the initial bundle.
import { IntlProvider } from 'react-intl';
const loadMessages = async (locale: string): Promise<Record<string, string>> => {
const m = await import(`./locales/${locale}.json`);
return m.default;
};
const App = () => {
const [locale, setLocale] = useState('en');
const [messages, setMessages] = useState<Record<string, string>>({});
useEffect(() => { loadMessages(locale).then(setMessages); }, [locale]);
return (
<IntlProvider locale={locale} messages={messages}>
<Routes />
</IntlProvider>
);
};
Vite code-splits each ./locales/*.json into its own chunk. The user downloads only their locale.
ICU messages — the standard for pluralisation, gender, and date/number formatting:
{count, plural, one {# invoice} other {# invoices}}
<FormattedMessage id="invoice.count" values={{ count: invoices.length }} />
The plural rules are locale-specific (English has two forms; Arabic has six; Russian has three). The library handles it; you write the ICU string once.
Build-time vs runtime
For huge catalogs, compile messages to a faster runtime format at build time (@formatjs/cli). Saves parse time per locale on every page load.
🔹 28.4 Accessibility compliance pipelines
Enterprise contracts often require WCAG 2.2 AA compliance. CI is where you enforce it.
Automated checks in CI
# .github/workflows/a11y.yml
- name: Run axe-core against Storybook
run: |
npm run build-storybook
npx http-server storybook-static -p 6006 &
sleep 3
npx @storybook/test-runner --url http://localhost:6006
Storybook’s addon-a11y (Ch 11.5.1) runs axe-core on every story. Combined with the test-runner, you fail CI on a11y regressions per component.
Per-PR Lighthouse checks
- uses: treosh/lighthouse-ci-action@v11
with:
urls: |
${{ steps.preview-deploy.outputs.url }}
${{ steps.preview-deploy.outputs.url }}/app
budgetPath: ./lighthouse-budget.json
lighthouse-budget.json declares minimums (performance: 85, accessibility: 95). PRs that drop below either fail.
Manual checks (still necessary)
Automated tools catch ~30 % of WCAG violations. The other 70 % needs:
- Keyboard-only navigation pass per release.
- Screen-reader spot-check per release (VoiceOver / NVDA).
- Color-contrast audit against
prefers-contrast: more.
Build the rhythm into your QA process. Once a quarter for an internal app; per-release for a customer-facing one.
🪤 Common Pitfalls
- SPA-only auth with localStorage tokens — XSS-readable; not 2026-grade.
- Refresh tokens accessible to JS — same problem.
- OIDC without verifying
state— CSRF vulnerability. - Feature flags with no expiry policy — accumulate forever.
- i18n bundling all locales upfront — bloated initial download.
- Hardcoded English in components — easy now, painful later.
- Lighthouse passing locally but not in CI — non-reproducible setup.
- Treating axe-core 100 % as “fully accessible” — it catches 30 %.
✅ Recap
- BFF +
httpOnlycookies is the modern enterprise auth pattern; extends Ch 5’s example. - Per-tenant theming = CSS variables; feature flags = OpenFeature + a provider.
- i18n lazy-loads per locale; ICU handles pluralisation.
- A11y in CI catches the cheap 30 %; humans catch the rest.
🔗 Further Reading
- https://openid.net/connect/ — OIDC Core 1.0 spec.
- https://openfeature.dev — OpenFeature spec + Web SDK 1.x.
- https://formatjs.io — FormatJS / react-intl docs.
- https://www.w3.org/WAI/standards-guidelines/wcag/ — WCAG 2.2 (W3C Recommendation).
- Ch 5 (the auth flow this chapter extends), Ch 11 (theming).
In the book — not on the site
Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.