3 min read

Type‑Safe Dependency Injection for React Server Components: A Pragmatic Guide

Learn how to build a compile‑time‑checked DI container that works seamlessly with React Server Components and Next.js.
Type‑Safe Dependency Injection for React Server Components: A Pragmatic Guide

Introduction

React Server Components (RSC) open a new frontier for building data‑rich pages that run only on the server. Because they never ship JavaScript to the client, they eliminate a whole class of runtime bugs, but they also change the way we think about dependency management.

In a traditional client‑side React app you often reach for Context or a third‑party DI library (Inversify, tsyringe, etc.). In the server‑only world those patterns feel heavyweight, and the lack of a clear, type‑safe contract can quickly lead to:

  • Implicit imports that are hard to mock in tests.
  • Runtime “undefined is not a function” errors when a service isn’t wired correctly.
  • Hard‑to‑track circular dependencies that surface only after the component has rendered.

This article shows how to create a lightweight, type‑safe DI container that works natively with RSC, keeps the server bundle small, and gives you compile‑time guarantees about the shape of every dependency. The approach is framework‑agnostic, but the examples are tied to Next.js 13+ App Router because that is the most common place to use RSC today.

TL;DR – Define a Container interface, register concrete implementations once (e.g., in app/lib/container.ts), and let TypeScript infer the exact type of each resolved service. Pass the container (or specific services) down the component tree via props, never via React Context, to stay within the RSC constraints.

1. Why a Custom Container?

  • Zero runtime overhead – RSC are streamed to the client; any runtime indirection adds latency.
  • Full type inference – A generic resolve<T>() method can be typed to return exactly the service you asked for.
  • Explicit wiring – All registrations happen in a single file, making it trivial to audit for circular references.
  • Testability – Swap the real container for a mock one in unit tests without touching component code.

2. Core Types

// lib/container.ts
export interface ServiceMap {
  /** Add concrete services here */
  logger: Logger;
  userRepo: UserRepository;
  featureFlag: FeatureFlagClient;
}

/**
 * The DI container – a thin wrapper around a plain object.
 * The generic `K` restricts lookup to keys of ServiceMap.
 */
export class Container {
  private readonly services: Partial<ServiceMap>;

  constructor(services: Partial<ServiceMap> = {}) {
    this.services = services;
  }

  /** Register a concrete implementation */
  register<K extends keyof ServiceMap>(key: K, impl: ServiceMap[K]): this {
    this.services[key] = impl;
    return this;
  }

  /** Resolve a service – compile‑time guarantees the key exists */
  resolve<K extends keyof ServiceMap>(key: K): ServiceMap[K] {
    const svc = this.services[key];
    if (!svc) {
      throw new Error(`Service "${String(key)}" not registered`);
    }
    return svc as ServiceMap[K];
  }
}

ServiceMap is the single source of truth for all injectable types. Adding a new service automatically updates the container’s API, and any typo will be caught by the compiler.


3. Implementing Real Services

Below are three realistic services you typically need in a server‑side page.

// lib/logger.ts
export interface Logger {
  info(message: string, meta?: Record<string, unknown>): void;
  error(message: string, err: unknown): void;
}
export class ConsoleLogger implements Logger {
  info(msg, meta) {
    console.log(`ℹ️ ${msg}`, meta ?? '');
  }
  error(msg, err) {
    console.error(`❌ ${msg}`, err);
  }
}

// lib/user-repo.ts
export interface User {
  id: string;
  name: string;
}
export interface UserRepository {
  findById(id: string): Promise<User | null>;
}
export class PrismaUserRepo implements UserRepository {
  async findById(id: string) {
    // pretend this hits Prisma
    return { id, name: 'Jane Doe' };
  }
}

// lib/feature-flag.ts
export interface FeatureFlagClient {
  isEnabled(flag: string): Promise<boolean>;
}
export class SimpleFlagClient implements FeatureFlagClient {
  private readonly map = new Map<string, boolean>([['beta', true]]);
  async isEnabled(flag: string) {
    return this.map.get(flag) ?? false;
  }
}

All services are pure TypeScript contracts (interfaces) plus a concrete implementation. Nothing in these files knows about React or Next.js, keeping the domain logic isolated.


4. Wiring the Container Once

Create a singleton that lives for the lifetime of the server process.

// lib/container.ts (continued)
import { ConsoleLogger } from