Client-Side Provider Registration Pattern
This document defines the pattern for optional dependency injection in client-side ("use client") packages. It complements the server-side patterns which use explicit factory functions.
Overview
Client-side packages use module-level register*Provider() functions to enable optional integrations (Sentry, analytics, etc.) without creating hard dependencies. Host apps call these registration functions at initialization to wire up their specific implementations.
The Pattern
// packages/app-video-flow/src/web/analytics/posthog.ts
"use client";
type SentryLike = { captureException: (e: Error) => void };
let _sentryProvider: SentryLike | null = null;
/**
* Register a Sentry provider for error capture.
* Call this in the host app's client providers.
*/
export function registerSentryProvider(sentry: SentryLike): void {
_sentryProvider = sentry;
}
// Usage in the same module
function safeCapture(event: string, props: Record<string, unknown>) {
try {
posthog.capture(event, props);
} catch (e) {
if (_sentryProvider && e instanceof Error) {
_sentryProvider.captureException(e);
}
}
}
Host app registers at initialization:
// apps/branded-video-flow/app/_providers/client-providers.tsx
"use client";
import * as Sentry from "@sentry/nextjs";
import { registerSentryProvider } from "@repo/app-video-flow/web";
// Register at module load (before any component renders)
registerSentryProvider(Sentry);
export function ClientProviders({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
Existing Registrations
| Package | Function | Registered At | Purpose |
|---|---|---|---|
@repo/app-video-flow/web | registerWebAnalyticsProvider | Module level | PostHog environment config |
@repo/app-video-flow/web | registerWebBlobFactoryProvider | Component body | Blob storage factory (called on each render — see note below) |
@repo/app-video-flow/web | registerSentryProvider | Module level | Error capture in analytics |
@repo/app-video-flow/web | registerBreadcrumbProvider | Module level | Sentry breadcrumbs in logger |
@repo/app-video-flow/web | registerErrorCaptureProvider | Module level | Error capture in logger |
@repo/app-video-flow/web | registerMessageCaptureProvider | Module level | Message capture in logger |
@repo/app-video-flow/web | registerLayoutTelemetrySentry | Module level | Layout overflow reporting |
@repo/app-hello/web | registerBreadcrumbProvider | Module level | Sentry breadcrumbs |
@repo/app-hello/web | registerErrorCaptureProvider | Module level | Error capture in logger |
@repo/app-hello/web | registerMessageCaptureProvider | Module level | Message capture in logger |
@repo/app-hello/web | registerLoggerProvider | Module level | Custom logger configuration |
Note on
registerWebBlobFactoryProvider: Inapps/branded-video-flow/app/_providers/client-providers.tsxthis is called inside the component function body rather than at module level. Since the provider setter is idempotent this works correctly, but deviates from the recommended module-level pattern. See kanban taskarch-improvement-bvf-blob-factory-registration.
Why This Is Safe Client-Side
Module-level globals work reliably in client-side code because:
-
Single Bundle: The client JavaScript is bundled into a single file (or code-split chunks). Module state is shared across all code in that bundle.
-
Single Browser Context: There's only one JavaScript runtime per browser tab. All components share the same module instances.
-
Deterministic Initialization: Registration happens at module load time, before React renders. By the time components call
getLogger()or similar, providers are already registered. -
No Worker Boundaries: Unlike server-side Next.js, there are no separate workers with isolated module graphs in the browser.
-
HMR Is Recoverable: During development, Hot Module Replacement may reset module state, but registration functions are called again when modules reload.
Why This Does NOT Work Server-Side
Server-side Next.js has fundamentally different runtime characteristics:
| Issue | Client | Server |
|---|---|---|
| Workers | Single thread | Multiple workers with separate module graphs |
| Module duplication | One instance per module | Can have duplicates via transpilePackages paths |
| HMR behavior | Modules reload together | Modules can reload independently |
| Request isolation | N/A (stateful) | Each request may run in different worker |
Server-Side Failure Modes
// ❌ DANGEROUS: Module global on server
let _serverProvider: SomeProvider | null = null;
export function registerServerProvider(p: SomeProvider) {
_serverProvider = p; // Only registers in ONE worker!
}
// Later, in a different worker's request:
export function getServerProvider() {
return _serverProvider; // null! Different worker never saw registration
}
Correct Server-Side Pattern
For server-side code, use the App-Level Clients pattern:
// packages/auth/src/server/organization-client.ts
export function createOrganizationClient(deps: {
env: { SUPERADMIN_ORG_IDS?: string[] },
logger: LoggerLike // mandatory for error logging
}): OrganizationClient {
// Explicit dependency injection via factory
}
// apps/library/app/_providers/server/clients.ts
import { getLibraryEnvironment } from "@/lib/env";
import { getLogger } from "@/lib/logger";
import { createOrganizationClient } from "@repo/auth/server/providers";
let _clients: Clients | null = null;
export function getClients(): Clients {
if (_clients) return _clients;
const env = getLibraryEnvironment();
const logger = getLogger();
_clients = { organization: createOrganizationClient({ env, logger }) };
return _clients;
}
Decision Guide
| Scenario | Pattern | Location |
|---|---|---|
| Client-side optional integration (Sentry, analytics) | register*Provider() | Package web/ exports |
| Client-side required config (env, blob factory) | register*Provider() | Package web/ exports |
| Server-side client/service | create*Client(deps) factory | Package server/ exports |
| Server-side env access | get*Environment() singleton | App lib/env.ts |
Best Practices
-
Always use
"use client": Provider registration modules must be client-only. -
Register at module level: Call
register*Provider()at the top of your client providers file, outside any component. -
Graceful fallbacks: Check if provider is registered before using:
if (_sentryProvider) { _sentryProvider.captureException(error); } -
Type the interface narrowly: Only require the methods you actually use:
// ✅ Good - minimal interface type SentryLike = { captureException: (e: Error) => void }; // ❌ Avoid - importing full Sentry types creates dependencies import type * as Sentry from "@sentry/nextjs"; -
Document the registration: Add JSDoc explaining how host apps should register:
/** * Register a Sentry provider for error capture. * Call this in the host app's client providers. * * @example * import * as Sentry from "@sentry/nextjs"; * registerSentryProvider(Sentry); */ export function registerSentryProvider(sentry: SentryLike): void { _sentryProvider = sentry; }
Related Documentation
- Server-Side Providers and DI - Factory-based DI for server code
- Environment Patterns - Environment variable access patterns
- Demo App Architecture - How the demo app works without providers