All docs/@repo/core

docs/architecture/client-providers-and-registration.md

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

PackageFunctionRegistered AtPurpose
@repo/app-video-flow/webregisterWebAnalyticsProviderModule levelPostHog environment config
@repo/app-video-flow/webregisterWebBlobFactoryProviderComponent bodyBlob storage factory (called on each render — see note below)
@repo/app-video-flow/webregisterSentryProviderModule levelError capture in analytics
@repo/app-video-flow/webregisterBreadcrumbProviderModule levelSentry breadcrumbs in logger
@repo/app-video-flow/webregisterErrorCaptureProviderModule levelError capture in logger
@repo/app-video-flow/webregisterMessageCaptureProviderModule levelMessage capture in logger
@repo/app-video-flow/webregisterLayoutTelemetrySentryModule levelLayout overflow reporting
@repo/app-hello/webregisterBreadcrumbProviderModule levelSentry breadcrumbs
@repo/app-hello/webregisterErrorCaptureProviderModule levelError capture in logger
@repo/app-hello/webregisterMessageCaptureProviderModule levelMessage capture in logger
@repo/app-hello/webregisterLoggerProviderModule levelCustom logger configuration

Note on registerWebBlobFactoryProvider: In apps/branded-video-flow/app/_providers/client-providers.tsx this 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 task arch-improvement-bvf-blob-factory-registration.

Why This Is Safe Client-Side

Module-level globals work reliably in client-side code because:

  1. 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.

  2. Single Browser Context: There's only one JavaScript runtime per browser tab. All components share the same module instances.

  3. Deterministic Initialization: Registration happens at module load time, before React renders. By the time components call getLogger() or similar, providers are already registered.

  4. No Worker Boundaries: Unlike server-side Next.js, there are no separate workers with isolated module graphs in the browser.

  5. 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:

IssueClientServer
WorkersSingle threadMultiple workers with separate module graphs
Module duplicationOne instance per moduleCan have duplicates via transpilePackages paths
HMR behaviorModules reload togetherModules can reload independently
Request isolationN/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

ScenarioPatternLocation
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/servicecreate*Client(deps) factoryPackage server/ exports
Server-side env accessget*Environment() singletonApp lib/env.ts

Best Practices

  1. Always use "use client": Provider registration modules must be client-only.

  2. Register at module level: Call register*Provider() at the top of your client providers file, outside any component.

  3. Graceful fallbacks: Check if provider is registered before using:

    if (_sentryProvider) {
      _sentryProvider.captureException(error);
    }
    
  4. 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";
    
  5. 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