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
serverorserver/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
createXClientfactories 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.