← Back to Blog
Next.js2026-05-13·66 min read

Quinze lignes de Proxy pour qu'un SDK ne casse plus mon CI

By Michel Faure

Current Situation Analysis

Modern serverless deployment platforms have fundamentally changed how frontend frameworks handle module initialization. When you run a build command in Next.js, the compiler doesn't just transpile TypeScript to JavaScript. It statically analyzes your file tree, resolves route definitions, and executes the top-level scope of every imported module to build an accurate dependency graph. This behavior is intentional: it enables route pre-rendering, tree-shaking, and serverless function bundling.

The friction emerges when third-party SDKs enforce credential validation at instantiation time. Libraries like Stripe, Twilio, AWS SDK v3, and various AI providers follow the fail-fast principle: they verify API keys in their constructors and throw immediately if credentials are missing. In a traditional monolithic server, this is a safety net. In a serverless preview environment, it becomes a build breaker.

Preview deployments and CI pipelines intentionally restrict sensitive environment variables for security and cost reasons. A webhook handler or an admin-only endpoint might be the only consumer of a payment SDK, yet its mere import triggers constructor execution during next build. The build fails before any request is ever routed. Developers often misattribute this to framework bugs or misconfigured CI variables, when the real issue is a mismatch between build-time artifact generation and runtime configuration boundaries.

The problem is systemic. It affects any SDK that validates credentials synchronously at the top level. It compounds when teams adopt ephemeral preview environments, where secrets are stripped by default. The result is a fragile CI pipeline that rejects valid code because a peripheral module expects production credentials during a static analysis phase.

WOW Moment: Key Findings

The architectural shift from always-on servers to ephemeral build environments requires decoupling module loading from credential validation. The following comparison demonstrates why deferring initialization outperforms traditional patterns in modern serverless workflows.

Approach Build Stability Runtime Safety Code Intrusiveness Performance Overhead
Top-Level Instantiation Fails without env vars Immediate crash on missing key Zero None
Lazy Proxy Pattern Passes consistently Fails on first actual call Low (single wrapper) Negligible (Proxy + cache)
Mock/Dry-Run Client Passes consistently Silent failures possible High (SDK-specific) Low
Env Guard + Conditional Export Passes consistently Runtime error on access Medium (multiple branches) None

This finding matters because it isolates the build phase from runtime configuration requirements. By deferring SDK instantiation until the first property access, you preserve the fail-fast guarantee where it actually counts: during request handling. The pattern also maintains 100% API compatibility, meaning existing consumers require zero refactoring. Teams can safely strip secrets from preview environments without breaking CI, while retaining strict validation in production.

Core Solution

The fix relies on intercepting property access rather than executing initialization code upfront. Instead of exporting a concrete SDK instance, you export a Proxy object that delegates to a lazily initialized singleton. The proxy remains dormant during build time and only triggers credential validation when a route actually invokes the SDK.

Step-by-Step Implementation

  1. Create a lazy initialization factory: A function that checks for required environment variables, throws if missing, and caches the resulting client.
  2. Wrap the export in a Proxy: The proxy intercepts all property reads. On first access, it calls the factory, caches the instance, and delegates the operation.
  3. Bind methods to the original context: SDK methods often rely on internal this references. The proxy must explicitly bind functions to the cached client to preserve execution context.
  4. Maintain strict TypeScript typing: Use generics to ensure the proxy matches the SDK's exact interface, preventing type drift.

New Code Example

The following implementation uses a reusable factory pattern. It demonstrates how to wrap a hypothetical @acme/payments SDK while preserving full type safety and execution context.

// lib/payment-gateway.ts
import { PaymentClient, PaymentClientConfig } from '@acme/payments';

type PaymentSDK = PaymentClient;

// Module-level cache prevents redundant instantiation
let cachedClient: PaymentSDK | null = null;

function resolvePaymentClient(): PaymentSDK {
  if (cachedClient) return cachedClient;

  const apiKey = process.env.PAYMENT_API_KEY;
  if (!apiKey) {
    throw new Error(
      'PAYMENT_API_KEY is required. Ensure it is set in the runtime environment.'
    );
  }

  const config: PaymentClientConfig = {
    environment: process.env.NODE_ENV === 'production' ? 'live' : 'sandbox',
    timeout: 5000,
  };

  cachedClient = new PaymentClient(apiKey, config);
  return cachedClient;
}

// Proxy factory with explicit type preservation
export const paymentGateway: PaymentSDK = new Proxy({} as PaymentSDK, {
  get(target, property, receiver) {
    const client = resolvePaymentClient();
    const originalValue = Reflect.get(client, property, receiver);

    // Preserve `this` context for SDK methods
    if (typeof originalValue === 'function') {
      return originalValue.bind(client);
    }

    return originalValue;
  },
});

Architecture Decisions & Rationale

Why a Proxy instead of a factory function?
Exporting getPaymentClient() forces every consumer to change paymentGateway.createCharge() to getPaymentClient().createCharge(). In a mature codebase, this requires touching dozens of files. A proxy maintains the exact same public API surface, enabling zero-impact adoption.

Why cache the instance?
SDKs often maintain internal state: connection pools, rate limiters, retry backoff timers, and HTTP keep-alive sessions. Creating a new client on every property access would fragment state, exhaust connection limits, and degrade performance. The module-level cache guarantees a single, consistent instance per serverless invocation.

Why Reflect.get + bind?
JavaScript proxies intercept property reads but do not automatically preserve execution context. When a method is accessed through a proxy, this inside the method points to the proxy target (an empty object), not the original SDK instance. Reflect.get retrieves the unbound method, and .bind(client) permanently attaches it to the correct context. Skipping this step results in TypeError: Cannot read properties of undefined during runtime.

Why defer validation?
Build environments are static analysis phases. They do not handle requests. Validating credentials during build conflates artifact generation with runtime configuration. Deferring validation to first access keeps CI green for preview deployments while preserving strict failure guarantees when the code actually executes.

Pitfall Guide

1. Forgetting Method Binding

Explanation: SDK methods rely on internal this references for configuration, headers, and retry logic. Without binding, this resolves to the proxy target, causing silent failures or runtime crashes. Fix: Always check typeof value === 'function' and apply .bind(client) before returning.

2. Skipping the Instance Cache

Explanation: Omitting the module-level singleton causes the factory to run on every property access. This multiplies HTTP connections, resets rate limiters, and breaks stateful SDK behaviors. Fix: Declare let cachedClient: SDKType | null = null at module scope. Return early if truthy.

3. Over-Applying the Pattern to Core SDKs

Explanation: If an SDK is required for every page render or global layout, deferring initialization only moves the crash from build time to first render. This degrades user experience without solving the root problem. Fix: Reserve the pattern for peripheral routes (webhooks, admin panels, cron handlers). Core SDKs should have credentials available in all environments.

4. TypeScript Type Drift

Explanation: Proxies can lose strict typing if not explicitly cast. Consumers may receive any or miss autocomplete, leading to runtime mismatches. Fix: Use new Proxy({} as SDKType, { ... }) and export with the exact SDK interface. Add // @ts-expect-error only if the SDK uses internal symbols that TypeScript cannot resolve.

5. Ignoring Async Initialization Requirements

Explanation: Some modern SDKs require await client.initialize() or async credential refreshers. The synchronous proxy pattern will block or fail. Fix: For async SDKs, wrap the proxy in a top-level await or use a promise-based cache: let initPromise: Promise<SDKType> | null = null. Resolve the promise inside the get trap and handle pending states gracefully.

6. Proxying Non-Existent Properties Silently

Explanation: The proxy returns undefined for missing properties, which can mask typos or deprecated API usage until runtime. Fix: Add a fallback in the get trap that throws a descriptive error when the property is undefined and not a standard object method.

7. Assuming Build Environments Mirror Production

Explanation: Teams often assume .env.local or CI variables are universally available. Preview environments intentionally restrict secrets for security compliance. Fix: Document which routes require which secrets. Use environment-specific validation scripts or CI checks that verify secret availability before deployment.

Production Bundle

Action Checklist

  • Audit top-level SDK imports across your codebase and identify modules executed during build
  • Classify SDKs by usage frequency: core (every page) vs peripheral (webhooks, admin, cron)
  • Implement the lazy proxy pattern only for peripheral SDKs with environment-restricted credentials
  • Verify TypeScript types match the original SDK interface exactly
  • Add explicit bind logic for all method returns in the proxy trap
  • Test the pattern in a preview environment with secrets intentionally omitted
  • Monitor first-call latency in production to ensure proxy overhead remains negligible
  • Document the pattern in your team's architecture guidelines to prevent regression

Decision Matrix

Scenario Recommended Approach Why Cost Impact
Webhook endpoint (rarely called) Lazy Proxy Defers validation until actual request; keeps preview builds green Zero
Admin dashboard (internal only) Lazy Proxy Credentials not needed in public preview; maintains DX Zero
Core authentication SDK Top-Level Init Required on every page; fail-fast prevents silent auth bypasses Low (env management)
SDK with async initialization Promise-Cached Proxy Handles await requirements while preserving lazy evaluation Low (slight complexity)
SDK with built-in mock mode Conditional Mock Uses official dry-run path; avoids proxy overhead Zero

Configuration Template

Copy this template into your project. Replace SDKType, SDK_PACKAGE, and environment variable names to match your stack.

// lib/lazy-sdk-proxy.ts
import { SDKType } from 'SDK_PACKAGE';

let instanceCache: SDKType | null = null;

function initializeSDK(): SDKType {
  if (instanceCache) return instanceCache;

  const requiredKey = process.env.YOUR_SDK_API_KEY;
  if (!requiredKey) {
    throw new Error(
      'YOUR_SDK_API_KEY is missing. Set it in the runtime environment before invoking this module.'
    );
  }

  // Replace with your SDK's actual constructor signature
  instanceCache = new SDKType(requiredKey, {
    // SDK-specific configuration
  }) as SDKType;

  return instanceCache;
}

export const sdkClient: SDKType = new Proxy({} as SDKType, {
  get(_target, prop, receiver) {
    const client = initializeSDK();
    const value = Reflect.get(client, prop, receiver);

    if (typeof value === 'function') {
      return value.bind(client);
    }

    return value;
  },
});

Quick Start Guide

  1. Identify the target module: Locate the file that imports and instantiates the problematic SDK at the top level.
  2. Replace direct instantiation: Remove new SDK(...) from the top level. Create a initializeSDK() function that validates env vars and caches the result.
  3. Wrap the export: Export a Proxy object that delegates to initializeSDK() on property access. Apply .bind() to functions.
  4. Verify types: Ensure the exported proxy matches the SDK's TypeScript interface. Run tsc --noEmit to catch type mismatches.
  5. Test in isolation: Trigger a preview build without the secret. Confirm the build passes. Deploy to production and verify the first API call fails cleanly if the key is missing.