Skip to content

Full-Stack Frameworks

Full-stack examples use scopes for loaders, actions, route handlers, and server actions. Development builds cache the root on globalThis to avoid duplicate clients during HMR.

Both examples share examples/_shared/container.ts. Compare the operation boundary that each framework awaits.

ExampleShows
next-app-router.tsNext.js App Router request and Server Action scope boundaries
remix.tsRemix loader and action scope boundaries

Next.js App Router

ts
'use server'

import { revalidatePath } from 'next/cache'

import { buildRootContainer, type RootContainer } from '../_shared/container.js'

// In development, HMR can reload this module without resetting globalThis.
// Reusing the root avoids leaking DB/cache clients on every file save and
// guarantees that across reloads `buildRootContainer()` runs ONCE, so the
// async `Database` factory in `_shared/container.ts` is initialized once.
const globalForInferDI = globalThis as typeof globalThis & {
  __inferdiNextRoot?: RootContainer
}

export const root: RootContainer =
  process.env.NODE_ENV === 'production'
    ? buildRootContainer()
    : (globalForInferDI.__inferdiNextRoot ??= buildRootContainer())

// Server Actions run as a single bounded async unit and Next awaits the
// function before returning to the client. `await using` is the right tool
// here: the scope is asyncDispose'd exactly when the action finishes,
// including on thrown errors.
export async function getProfileAction(formData: FormData) {
  await using scope = root.createScope()
  const ctx = scope.get('request')
  ctx.requestId = crypto.randomUUID()
  ctx.userId = String(formData.get('userId') ?? '') || undefined

  const profile = await scope.get('users').profile(ctx.userId ?? 'me')
  revalidatePath('/profile')
  return profile
}

Repository file: examples/fullstack/next-app-router.ts

Remix

ts
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from '@remix-run/node'
import { json } from '@remix-run/node'

import {
  buildRootContainer,
  createRequestScope,
  type RootContainer,
} from '../_shared/container.js'

// Remix dev can re-evaluate modules during HMR while globalThis survives.
// Cache the root so the async Database factory is not re-run on every save.
const globalForInferDI = globalThis as typeof globalThis & {
  __inferdiRemixRoot?: RootContainer
}

export const root: RootContainer =
  process.env.NODE_ENV === 'production'
    ? buildRootContainer()
    : (globalForInferDI.__inferdiRemixRoot ??= buildRootContainer())

async function scopeFor(request: Request) {
  return createRequestScope(root, {
    requestId: request.headers.get('x-request-id') ?? crypto.randomUUID(),
    userId: request.headers.get('x-user-id') ?? undefined,
  })
}

export async function loader({ request }: LoaderFunctionArgs) {
  // Loader / action functions are bounded async operations that Remix awaits
  // before serializing the response. `await using` ties scope disposal to
  // the same boundary — Remix never streams data out of a loader after it
  // returns, so the scope is safe to tear down here.
  await using scope = await scopeFor(request)
  return json(await scope.get('users').profile('me'))
}

export async function action({ request }: ActionFunctionArgs) {
  await using scope = await scopeFor(request)
  const form = await request.formData()
  return json({ ok: true, fields: [...form.keys()] })
}

Repository file: examples/fullstack/remix.ts