All docs/@repo/core

docs/architecture/server-providers-and-di.md

App-Level Clients (Next.js 15)

This document defines the monorepo-wide pattern: apps construct typed clients with their own env and logger, then call those clients everywhere. Packages export client factories; packages never read env directly.

Goals

  • Deterministic provider availability for Server Components and API routes
  • No import-time environment access in packages
  • Safe runtime behavior in multi-worker (serverless/dev) environments

Core Rules

  • Packages export factories like createXClient(deps); no global singletons inside packages.
  • Apps create module‑scoped singletons (getClients()) that lazily construct clients using the app env getter and logger.
  • Components, routes, and actions call into these clients (not package internals directly).

Patterns

Package Client Surface

Package example:

// packages/auth/src/server/organization-client.ts
export interface OrganizationClient { /* methods */ }
export function createOrganizationClient(deps: { env: { SUPERADMIN_ORG_IDS?: string[] }, logger: LoggerLike }): OrganizationClient {
  // build and return client using deps
  // logger is mandatory for error logging
}

App Singleton (module‑scoped)

// apps/library/app/_providers/server/clients.ts
import { getLibraryEnvironment } from "@/lib/env";
import { Logger } from "@repo/core/server/logger";
import { createOrganizationClient, type OrganizationClient } from "@repo/auth/server/providers";

type Clients = { organization: OrganizationClient; logger: Logger };
let _clients: Clients | null = null;

export function getClients(): Clients {
  if (_clients) return _clients;
  const env = getLibraryEnvironment();
  const logger = new Logger("library-app", { isProduction: env.ENVIRONMENT === "cloud", enableDebugLogging: env.ENVIRONMENT === "local" });
  _clients = { organization: createOrganizationClient({ env: { SUPERADMIN_ORG_IDS: env.SUPERADMIN_ORG_IDS }, logger }), logger };
  return _clients;
}

Usage in server components/routes:

const { organization } = getClients();
const result = await organization.checkOrganizationAuth();

Notes:

  • Import from intent surfaces like server or server/providers (not umbrella exports with heavy deps).
  • Keep runtime = "nodejs" when using Node APIs (ioredis, crypto, fs).

Instrumentation

Use for validation/telemetry only (Sentry/OTel). Do not register env or loggers here.

Linting

Keep imports consistent and intent-based; avoid relative internal hops inside packages that can create duplicate module graphs under transpilePackages.

5) Server Actions

Use plain exported async functions in app/actions/** with "use server" at the top of the file. Call into your app clients created via getClients().

// apps/<app>/app/actions/example.ts
"use server";
import { getClients } from "@/app/_providers/server/clients";

export async function doThing(state: { ok?: boolean }, formData: FormData) {
  const { organization } = getClients();
  await organization.verifySignedInUser();
  return { ok: true };
}

6) Inline Page Actions

For page‑local actions inside page.tsx, use "use server" and call your clients directly.

// apps/<app>/app/some/page.tsx
import { getClients } from "@/app/_providers/server/clients";

async function createAction(formData: FormData) {
  "use server";
  const { organization } = getClients();
  await organization.verifySignedInUser();
  // ...
}

Legacy

Previous docs referenced provider barrels/wrappers. We now prefer explicit app clients with env injection and simple module-scoped singletons.

Import Consistency to Avoid Duplicate Modules

When transpilePackages is enabled, avoid relative imports inside packages for shared state. Always import through the package export surface:

// ✅ Good (single module instance)
import { getAuthServerEnv } from "@repo/auth/server/providers";

// ❌ Bad (can create duplicate instances per path)
import { getAuthServerEnv } from "../server/env-provider";

This prevents multiple copies of singletons/providers under different paths and avoids "provider not registered" errors.

Why not import-based DI / side-effect providers

Next.js 15 uses multiple server workers and maintains separate module graphs per worker. In dev/HMR, modules can be reloaded independently. Relying on import-time side effects (e.g., "register provider on import") is brittle because:

  • Module duplication: when packages are listed in transpilePackages, the same source can be loaded via different paths, creating separate singleton instances.
  • Worker boundaries: a provider registered in one worker is not visible in another worker's module graph.
  • HMR reloads: import order and timing can change during hot reloads.

By constructing explicit clients (classes/factories) at the app boundary and passing env/loggers into them, initialization is deterministic and under your control. The class instantiation makes dependency flow explicit and predictable in every worker.

Summary

  • Apps: own env and logger; expose getClients().
  • Packages: export createXClient factories and types; never access env directly.
  • Call sites: use clients, not package internals.

General Rules

  • Keep clients server-only; don't leak server code into client bundles.
  • Instrumentation is for telemetry, not dependency registration.