Repository navigation
Request-scoped DI containers in App Router / RSC #95092
Replies: 6 comments 4 replies
|
Your approach looks broadly aligned with how App Router and RSC are intended to work. Using react.cache() as the mechanism for creating a request-scoped container is a sensible choice because it provides request-local memoization and allows nested Server Components to share the same container during a render. |
|
This is basically the right shape. Two things I'd flag before you lean on it too hard:
The "only pass serializable snapshots to the client" rule is the right call and honestly the part people get wrong most, so good that it's baked in from the start. |
|
Adding a few things to what's been said, since I hit this pattern building a multi-tenant SaaS on App Router and had to work through the disposal + Server Actions story carefully.
So AsyncLocalStorage is that second primitive. Wire it once at the top of every entry point (middleware, route handlers, server actions) so every code path shares one shape: // lib/container.ts
import { AsyncLocalStorage } from "node:async_hooks";
export type Container = { db: Db; s3: S3; tenantId: string };
export const containerStorage = new AsyncLocalStorage<Container>();
export const getContainer = () => {
const c = containerStorage.getStore();
if (!c) throw new Error("container missing — did you forget the wrapper?");
return c;
};Then a single wrapper you use everywhere: // lib/withContainer.ts
export async function withContainer<T>(tenantId: string, fn: () => Promise<T>) {
const container: Container = {
db: makeDb(tenantId),
s3: makeS3(tenantId),
tenantId,
};
try {
return await containerStorage.run(container, fn);
} finally {
// disposal — the piece React.cache doesn't give you
await container.db.[Symbol.asyncDispose]?.();
await container.s3.[Symbol.asyncDispose]?.();
}
}Now inside RSC, Route Handlers, and Server Actions the code is identical: export async function POST(req: Request) {
const tenantId = await resolveTenant(req);
return withContainer(tenantId, async () => {
const { db } = getContainer();
// ...
});
}
// server action
"use server";
export async function createPost(input: FormData) {
const tenantId = await resolveTenantFromSession();
return withContainer(tenantId, async () => {
const { db } = getContainer();
await db.insert(posts).values(fromForm(input));
revalidateTag(`t:${tenantId}:posts`);
});
}Combine with Inside a render pass, // inside RSC
const listPosts = cache(async () => {
const { db } = getContainer();
return db.select().from(posts);
});Two RSCs on the same page calling Two footguns that will bite you.
tl;dr — |
|
Yes — this split is exactly the shape I had in mind, and the way you've drawn the boundary between 1. "use server";
export async function publishPost(input: FormData) {
return withRequestContainer(async () => {
const { db, tenantId } = getRequestContainer();
const post = await db.insert(posts).values(fromForm(input)).returning();
// capture what the deferred job needs — don't rely on ALS post-await
const capturedTenantId = tenantId;
const capturedPostId = post[0].id;
unstable_after(async () => {
await withRequestContainer(async () => {
// fresh scope; ALS re-populated for this callback
const { search } = getRequestContainer();
await search.index(capturedTenantId, capturedPostId);
}, { tenantId: capturedTenantId });
});
revalidateTag(`t:${tenantId}:posts`);
});
}Worth calling out in the "detached/post-action work" section — this is the concrete API shape most people will trip over. 2. Edge runtime caveat. 3. Testing footnote. For the vitest side of things, The three-layer separation you settled on (render-scope / request-scope / client boundary) is the shape that actually holds up under production load. Nice work on the follow-up PR. |
|
That covers all three cleanly. Nice on the follow-up PR — the split between |
|
One additional area I'd consider documenting is how the container behaves during aborted or partially completed streaming renders. The distinction between render-scoped memoization and explicitly managed request scopes is clear in your updated design. A useful next step could be a small integration test matrix covering:
For explicit scopes, the tests could assert that cleanup runs exactly once, including failure paths. For RSC render-scoped containers, I'd avoid promising deterministic disposal unless the framework provides an appropriate lifecycle guarantee. This would make the lifecycle limitations observable rather than only documented. Have you already included cancellation or interrupted-stream tests in the demo? |
Uh oh!
There was an error while loading. Please reload this page.
Hi! I’m experimenting with a small TypeScript dependency injection library and recently added a Next.js App Router / RSC adapter.
Library:
https://www.npmjs.com/package/di-craft
https://github.com/bezmen-e/di-craft
Next.js App Router docs section:
https://github.com/bezmen-e/di-craft#nextjs-app-router
The idea is to keep the core DI container framework-agnostic and expose Next-specific behavior through separate subpath exports.
The model is:
React.cacheas the request memoization primitive for RSC;So instead of this:
it uses this:
The adapter also has an explicit helper for Route Handlers and Server Actions, where the request container can be disposed after the callback.
I prepared a small App Router demo that covers:
Demo source:
https://github.com/bezmen-e/di-craft-next-demo
Demo README:
https://github.com/bezmen-e/di-craft-next-demo#readme
Note: this demo is designed for a real local Node.js environment or Vercel deployment. StackBlitz/WebContainer environments may fail with current Next.js dev runtime limitations around Turbopack/Webpack fallback and RSC internals.
A few things I’m trying to validate:
React.cachethe right primitive for request-scoped userland containers in RSC?server state -> serializable snapshot -> client statemodel fit App Router boundaries well?This is not a request for framework integration. I’m mostly trying to validate whether this userland pattern is aligned with Next.js/RSC semantics.
I’d be happy to hear any feedback, corrections, or advice on this approach, especially around request lifecycle, disposal, and state snapshots in App Router/RSC.
All reactions