modern-react-spa

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 httpOnly cookie 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:

  1. Before redirect, BFF generates a random state value, stores it in the user’s session (cookie).
  2. Redirects to IdP with state=....
  3. IdP redirects back to /api/auth/callback?code=...&state=....
  4. BFF verifies the returned state matches 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

  1. SPA-only auth with localStorage tokens — XSS-readable; not 2026-grade.
  2. Refresh tokens accessible to JS — same problem.
  3. OIDC without verifying state — CSRF vulnerability.
  4. Feature flags with no expiry policy — accumulate forever.
  5. i18n bundling all locales upfront — bloated initial download.
  6. Hardcoded English in components — easy now, painful later.
  7. Lighthouse passing locally but not in CI — non-reproducible setup.
  8. Treating axe-core 100 % as “fully accessible” — it catches 30 %.

✅ Recap

  • BFF + httpOnly cookies 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

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.

Topics in this chapter (4)
  1. 28.1 Auth/SSO patterns — extending the Ch 5 example
  2. 28.2 Multi-tenancy — per-tenant theming and feature flags
  3. 28.3 i18n at scale — lazy-loaded locales, ICU messages
  4. 28.4 Accessibility compliance pipelines