Master Next.js Dependency Injection in Server Components. Learn how to architect request-scoped services for better testability and clean code.
When we moved our core platform to the Next.js App Router, we hit a wall. Managing database connections, authenticated user sessions, and logging context across deeply nested Server Components felt like passing props through ten layers of hell. We initially tried global singletons, but that crashed and burned the moment we needed request-specific isolation for our integration tests.
If you’re building non-trivial applications, you need a way to handle state that lives exactly as long as a single HTTP request. Here is how we implemented a proper Dependency Injection (DI) container pattern in Next.js.
In a standard Express or NestJS app, you have a long-lived container. In Next.js, the environment is ephemeral. Every request is a potential island. If you rely on shared global state, you’ll leak sensitive data between concurrent users—a security nightmare.
By implementing a DI container, you decouple your business logic from the underlying infrastructure. You stop importing your database client directly into your components. Instead, you inject an interface. This makes unit testing a breeze because you can swap the production client for a mock during your CI runs.
To make this work, we leverage AsyncLocalStorage from Node.js. It’s the secret sauce for tracking request-scoped data without passing it as an argument to every function. If you haven't explored this yet, check out my previous guide on Next.js AsyncLocalStorage: Type-Safe Request Context Injection to understand how we bridge the gap between incoming requests and Server Components.
Here is how we structure the container:
TYPESCRIPT// lib/container.ts import { AsyncLocalStorage } from CE9178">'async_hooks'; interface Container { db: DatabaseClient; auth: UserSession; logger: Logger; } export const containerStorage = new AsyncLocalStorage<Container>(); export const getContainer = () => { const container = containerStorage.getStore(); if (!container) throw new Error("Container not initialized"); return container; };
We initialize this container in our middleware or a high-level layout. By wrapping the request lifecycle, we ensure that every Server Component down the tree has access to the same scoped instances.
Wait—before you go full-blown DI, consider if you actually need it. Sometimes, Next.js Request Memoization: Stop Over-Fetching in Server Components is enough to solve your data-sharing problems. If your logic is simple, don't over-engineer it. Use DI only when you have complex service dependencies that need mocking or specific configuration.
When building our services, we define them as classes that accept their dependencies via the constructor. This is the "classic" DI approach.
TYPESCRIPT// services/UserService.ts export class UserService { constructor(private db: DatabaseClient) {} async getUser(id: string) { return this.db.users.findUnique({ where: { id } }); } }
In your Server Component, you don’t instantiate the service manually. You grab it from your container:
TSX// app/profile/[id]/page.tsx import { getContainer } from CE9178">'@/lib/container'; export default async function ProfilePage({ params }: { params: { id: string } }) { const { db } = getContainer(); const userService = new UserService(db); const user = await userService.getUser(params.id); return <div>{user.name}</div>; }
We initially tried to use a popular DI library, but it relied on decorators that caused issues with the Next.js build pipeline and SWC transpilation. We ended up writing a lightweight factory function instead. It’s about 40 lines of code, and it works perfectly across the App Router.
One trap we fell into: trying to inject the container into Client Components. Don't do it. Client Components are serialized and sent to the browser. Your DI container is a server-side concept. If you need data in a Client Component, fetch it in the Server Component and pass it as a prop, or use a Next.js App Router Data Provider Pattern for Clean Architecture to manage that boundary.
Q: Does this work with Server Actions?
A: Yes. Since Server Actions execute in the same request context, they can access the AsyncLocalStorage store just like Server Components.
Q: Will this hurt performance?
A: The overhead of AsyncLocalStorage is negligible (usually around 0.1ms per request). The clarity you gain in your architecture far outweighs the cost.
Q: How do I handle circular dependencies? A: If you find yourself in a circular loop, your services are likely doing too much. Break them into smaller, granular services.
I'm still tinkering with how to make the container type-safe without boilerplate. Right now, we maintain a manual interface, but I’ve been looking into automated registration patterns. For now, keep it simple, focus on the request lifecycle, and don't let the architecture become more complex than the problem you're solving.
Next.js request hedging is a powerful pattern to mitigate p99 latency. Learn how to implement speculative execution in Server Components to keep your apps fast.