Dynamic Modules
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. The examples use the Nest 12 CLI project layout (ESM, Vitest, supertest, @nestjs/config 12.0.1). The HTTP examples behave the same on Fastify 5, and the output shown is what the examples produce.
A static module is fixed at the point where its @Module() decorator is evaluated. That is perfect for application code, and wrong for reusable code: a module that talks to a search server, builds URLs for a CDN or prefixes identifiers needs the importer to say how. A dynamic module is a module whose metadata is produced by a static method that the importer calls, with arguments. This page explains the mechanism (the DynamicModule object), the naming conventions of the official packages (forRoot, register, forFeature), how options can be resolved through dependency injection (forRootAsync with useFactory, useClass and useExisting), and ConfigurableModuleBuilder, which generates all of that boilerplate. The running scenario is the Bookshelf application; for the basics of modules see Modules, and for the provider kinds used below see Providers & Dependency Injection.
Static binding and its limit
With a static module, the consumer can only list the module class. All the module’s providers are configured by the module itself, so every importer gets the same behaviour:
@Module({
imports: [PublicIdModule], // no way to say which prefix we want
providers: [BooksIds],
})
export class BooksModule {}
Bookshelf needs public identifiers that start with a type prefix (bk_ for books, au_ for authors), generated by the same logic. Only the prefix differs per consumer, which is exactly the situation static binding cannot express. A dynamic module fixes this: the importer passes the prefix when it imports.
The DynamicModule object
A dynamic module is an object with the same properties as the @Module() metadata (imports, providers, controllers, exports) plus a module property that references the module class. Only module is required. By convention the object is returned by a static method of the module class, and the method may take any arguments. Here is the identifier module, written by hand:
export interface PublicIdModuleOptions {
prefix: string;
}
export const PUBLIC_ID_OPTIONS = 'PUBLIC_ID_OPTIONS';
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
PUBLIC_ID_OPTIONS,
type PublicIdModuleOptions,
} from './public-id.interfaces.js';
@Injectable()
export class PublicIdService {
constructor(
@Inject(PUBLIC_ID_OPTIONS)
private readonly options: PublicIdModuleOptions,
) {}
generate(): string {
return `${this.options.prefix}_${randomUUID()}`;
}
}
import { DynamicModule, Module } from '@nestjs/common';
import {
PUBLIC_ID_OPTIONS,
type PublicIdModuleOptions,
} from './public-id.interfaces.js';
import { PublicIdService } from './public-id.service.js';
@Module({})
export class PublicIdModule {
static register(options: PublicIdModuleOptions): DynamicModule {
return {
module: PublicIdModule,
providers: [
{ provide: PUBLIC_ID_OPTIONS, useValue: options },
PublicIdService,
],
exports: [PublicIdService],
};
}
}
Three things happen in register():
-
The options become a provider with a token (
PUBLIC_ID_OPTIONS) and a value (useValue). Any value can be a provider, not only a class, which is how runtime data enters the container. -
PublicIdServicereceives the options with@Inject(PUBLIC_ID_OPTIONS), exactly like any non-class token (see Providers & Dependency Injection). -
exportsmakes the service visible to the importer. The empty@Module({})decorator is still required: a dynamic module is the static decoration plus the returned object.
The consumers import the module with their own options. The importing module’s imports array accepts module classes and the objects returned by static methods side by side:
import { Injectable, Module } from '@nestjs/common';
import { PublicIdModule } from './public-id.module.js';
import { PublicIdService } from './public-id.service.js';
@Injectable()
export class BooksIds {
constructor(readonly ids: PublicIdService) {}
}
@Injectable()
export class AuthorsIds {
constructor(readonly ids: PublicIdService) {}
}
@Module({
imports: [PublicIdModule.register({ prefix: 'bk' })],
providers: [BooksIds],
exports: [BooksIds],
})
export class BooksModule {}
@Module({
imports: [PublicIdModule.register({ prefix: 'au' })],
providers: [AuthorsIds],
exports: [AuthorsIds],
})
export class AuthorsModule {}
Running Test.createTestingModule({ imports: [BooksModule, AuthorsModule] }) and generating one identifier from each gives:
bk_65a400a0-6fc9-4778-9990-26e07515a62f au_1cdbf6b4-8120-43ef-8e86-7a78b284f0b4
The two PublicIdService instances are different objects, each configured by the module that imported it. The returned object has exactly the keys that were written (module, providers, exports). Two facts about how the returned metadata is combined are worth remembering:
-
It extends the static metadata; it does not replace it. If the
@Module()decorator listsproviders: [A]and the returned object listsproviders: [B], the module has both. This is what letsConfigurableModuleClass(below) add only the options provider while the decorator lists the real providers. -
Every call creates a configuration of its own. Calling
PublicIdModule.register({ prefix: 'x' })twice in two modules produces two different service instances, even though the options are equal; do not rely on Nest to merge equal-looking registrations. When one configuration must be shared, import the dynamic module once and re-export it, or make it global (see What a dynamic module can return).
The forRoot, register and forFeature conventions
The method name is yours, but the official packages follow three conventions, which are worth following in your own modules because readers recognise them:
| Method | Meaning | Typical use |
|---|---|---|
|
Configures the module for use only by the calling module. Another module may call it with different options and get a differently configured instance. |
Per-importer configuration, such as the |
|
Configures the module once and reuses that configuration in several places, often without the consumers being aware of it. |
One database connection, one GraphQL server, one |
|
Uses the configuration set up by |
Feature modules that need their own slice of a root-configured resource. |
|
The same meaning, but the options are resolved through dependency injection (see Async options: |
Options that come from |
The same names appear throughout the framework’s own packages: ConfigModule.forRoot() and ConfigModule.forFeature() (partial registration of configuration files), and TypeOrmModule.forRoot() and forFeature() (see Configuration and Data Overview & TypeORM). Those packages show the same split: a forRoot() that configures once, and a forFeature() that declares what a feature module wants from it.
Bookshelf’s search index follows that design. The root part (SearchIndexCoreModule) is global and holds the client; the public module (SearchIndexModule) exposes forRoot() and forFeature(). A feature module asks for named indices and injects them with a small decorator:
import type { FactoryProvider, ModuleMetadata, Type } from '@nestjs/common';
export interface SearchIndexOptions {
url: string;
exposeStatus?: boolean;
}
export interface SearchIndexOptionsFactory {
createSearchIndexOptions(): Promise<SearchIndexOptions> | SearchIndexOptions;
}
export interface SearchIndexAsyncOptions extends Pick<ModuleMetadata, 'imports'> {
useFactory?: (...args: any[]) => Promise<SearchIndexOptions> | SearchIndexOptions;
inject?: FactoryProvider['inject'];
useClass?: Type<SearchIndexOptionsFactory>;
useExisting?: Type<SearchIndexOptionsFactory>;
// Controllers are static metadata, so this flag cannot come from the async factory.
exposeStatus?: boolean;
}
export const SEARCH_INDEX_OPTIONS = Symbol('SEARCH_INDEX_OPTIONS');
export const getIndexToken = (name: string): string =>
`SEARCH_INDEX_${name.toUpperCase()}`;
import { Inject, Injectable } from '@nestjs/common';
import {
SEARCH_INDEX_OPTIONS,
type SearchIndexOptions,
} from './search-index.interfaces.js';
export class SearchIndex {
private readonly documents: string[] = [];
constructor(readonly name: string) {}
add(document: string): void {
this.documents.push(document);
}
search(term: string): string[] {
return this.documents.filter((d) => d.toLowerCase().includes(term.toLowerCase()));
}
}
@Injectable()
export class SearchIndexClient {
private readonly indices = new Map<string, SearchIndex>();
constructor(
@Inject(SEARCH_INDEX_OPTIONS) readonly options: SearchIndexOptions,
) {}
index(name: string): SearchIndex {
let index = this.indices.get(name);
if (!index) {
index = new SearchIndex(name);
this.indices.set(name, index);
}
return index;
}
names(): string[] {
return [...this.indices.keys()];
}
}
import {
Controller,
DynamicModule,
Get,
Inject,
Module,
type Provider,
} from '@nestjs/common';
import { SearchIndexClient } from './search-index.client.js';
import {
getIndexToken,
SEARCH_INDEX_OPTIONS,
type SearchIndexAsyncOptions,
type SearchIndexOptions,
type SearchIndexOptionsFactory,
} from './search-index.interfaces.js';
export const InjectIndex = (name: string) => Inject(getIndexToken(name));
@Controller('search-status')
export class SearchStatusController {
constructor(private readonly client: SearchIndexClient) {}
@Get()
status() {
return { url: this.client.options.url, indices: this.client.names() };
}
}
// The "core" module is configured once and global, like TypeOrmCoreModule.
@Module({})
export class SearchIndexCoreModule {
static forRoot(options: SearchIndexOptions): DynamicModule {
return {
module: SearchIndexCoreModule,
global: true,
providers: [
{ provide: SEARCH_INDEX_OPTIONS, useValue: options },
SearchIndexClient,
],
exports: [SearchIndexClient],
controllers: options.exposeStatus ? [SearchStatusController] : [],
};
}
static forRootAsync(options: SearchIndexAsyncOptions): DynamicModule {
return {
module: SearchIndexCoreModule,
global: true,
imports: options.imports ?? [],
providers: [...this.createAsyncProviders(options), SearchIndexClient],
exports: [SearchIndexClient],
controllers: options.exposeStatus ? [SearchStatusController] : [],
};
}
private static createAsyncProviders(options: SearchIndexAsyncOptions): Provider[] {
if (options.useFactory) {
return [
{
provide: SEARCH_INDEX_OPTIONS,
useFactory: options.useFactory,
inject: options.inject ?? [],
},
];
}
const factoryClass = (options.useClass ?? options.useExisting)!;
const optionsProvider: Provider = {
provide: SEARCH_INDEX_OPTIONS,
useFactory: (factory: SearchIndexOptionsFactory) =>
factory.createSearchIndexOptions(),
inject: [factoryClass],
};
// useClass registers the class itself; useExisting reuses a provider that already exists.
return options.useClass
? [optionsProvider, { provide: options.useClass, useClass: options.useClass }]
: [optionsProvider];
}
}
// The public facade: forRoot() configures, forFeature() adds named indices.
@Module({})
export class SearchIndexModule {
static forRoot(options: SearchIndexOptions): DynamicModule {
return { module: SearchIndexModule, imports: [SearchIndexCoreModule.forRoot(options)] };
}
static forRootAsync(options: SearchIndexAsyncOptions): DynamicModule {
return {
module: SearchIndexModule,
imports: [SearchIndexCoreModule.forRootAsync(options)],
};
}
static forFeature(names: string[]): DynamicModule {
const providers: Provider[] = names.map((name) => ({
provide: getIndexToken(name),
useFactory: (client: SearchIndexClient) => client.index(name),
inject: [SearchIndexClient],
}));
return {
module: SearchIndexModule,
providers,
exports: names.map(getIndexToken),
};
}
}
A feature module then reads like this, without knowing anything about the URL or the client:
@Injectable()
export class BookSearch {
constructor(@InjectIndex('books') readonly index: SearchIndex) {}
}
@Module({
imports: [SearchIndexModule.forFeature(['books'])],
providers: [BookSearch],
exports: [BookSearch],
})
export class BookSearchModule {}
The application imports SearchIndexModule.forRoot({ url: 'http://search.local' }) once, next to BookSearchModule. A document added through BookSearch is found through the shared SearchIndexClient, which shows that the feature module and the root share one client. The design has a precise failure mode: importing BookSearchModule without a forRoot() fails at bootstrap with
Nest can't resolve dependencies of the SEARCH_INDEX_BOOKS (?). Please make sure that the argument SearchIndexClient at index [0] is available in the SearchIndexModule module.
Why split into a "core" and a "facade" module? A single module cannot be both forRoot()-configured and a forFeature() host: forFeature() returns another instance of SearchIndexModule, one that has no options provider. Moving the root providers into a separate global module that forRoot() imports is the standard pattern, and it is what makes the feature module’s SearchIndexClient injection resolve.
What a dynamic module can return
Because the returned object has the shape of @Module() metadata, everything the decorator accepts can be decided at import time:
| Property | Use in a dynamic module |
|---|---|
|
The module class itself. It is the only property that is not optional. |
|
Providers that depend on the options: the options provider, services configured from it, or providers generated from a list ( |
|
What the importer may inject. A provider that is not exported stays private even to the module that imported the dynamic module. |
|
Other modules the dynamic module needs, including dynamic ones, such as |
|
Controllers registered only when the options ask for them. The |
|
|
global: true is the dynamic counterpart of @Global() (see Modules for why global modules should be rare). Libraries commonly expose it as an isGlobal option, which ConfigurableModuleBuilder supports as an "extra" (see Extra options: setExtras). A static method may also return a Promise<DynamicModule>: the imports array of a module accepts a promise, for example imports: [(async () ⇒ SearchIndexModule.forRoot({ url: 'p' }))()]. Prefer forRootAsync() for configuration that needs dependency injection.
When the static method runs
forRoot() and register() are plain function calls inside a decorator argument, so they run when the class that imports them is defined, which happens while the module files are loaded, before NestFactory has built the container. An async factory (useFactory, forRootAsync()) runs later, during container instantiation, and Nest awaits it before it creates the providers that depend on it. The next example records the order of events:
import { DynamicModule, Injectable, Module, type OnModuleInit } from '@nestjs/common';
export const events: string[] = [];
const OPTIONS = 'ORDERING_OPTIONS';
@Injectable()
export class Consumer implements OnModuleInit {
constructor() {
events.push('3 Consumer constructed');
}
onModuleInit() {
events.push('4 onModuleInit');
}
}
@Module({})
export class TimedModule {
static forRoot(): DynamicModule {
events.push('1 forRoot() called (while the @Module() metadata is evaluated)');
return { module: TimedModule, providers: [Consumer] };
}
static forRootAsync(): DynamicModule {
return {
module: TimedModule,
providers: [
{
provide: OPTIONS,
useFactory: async () => {
events.push('2 async factory started');
await new Promise((resolve) => setTimeout(resolve, 20));
events.push('2 async factory finished');
return {};
},
},
{
provide: Consumer,
useFactory: (): Consumer => new Consumer(),
inject: [OPTIONS],
},
],
};
}
}
Source: Asynchronous providers and Lifecycle events.
Importing TimedModule.forRoot() and bootstrapping produced
'1 forRoot() called (while the @Module() metadata is evaluated)'
'after decoration'
'3 Consumer constructed'
'4 onModuleInit'
and with forRootAsync() the events were
'2 async factory started'
'2 async factory finished'
'3 Consumer constructed'
'4 onModuleInit'
Two practical rules follow. First, do not read process.env inside forRoot() and expect values that ConfigModule loads from a .env file: they exist only if ConfigModule.forRoot() has already been evaluated, and nothing guarantees that order across modules. Read configuration in an async factory with ConfigService instead. Second, an async factory delays application start: a slow factory is a slow boot, and a factory that throws stops the application from starting, which is usually what you want for missing configuration. See Lifecycle Events & Shutdown for what runs after the container is built.
Async options: useFactory, useClass and useExisting
Static options are written in code. Real options (a URL, a credential reference, a feature flag) usually come from ConfigService or another provider, which exists only inside the container. The forRootAsync() convention lets the importer describe how to obtain the options with the same three provider forms that custom providers offer (see Providers & Dependency Injection):
| Property | Meaning |
|---|---|
|
A function, possibly |
|
A class that Nest instantiates inside the dynamic module as a provider; it must expose a factory method (conventionally |
|
The same contract, but reuses a provider that already exists instead of instantiating a second copy. That provider must be visible to the dynamic module (exported by a module listed in |
|
Modules whose exports the factory or the class needs (for example |
Writing forRootAsync() by hand means translating each form into providers. This is the implementation inside SearchIndexCoreModule (the createAsyncProviders method above), and it is exactly the boilerplate ConfigurableModuleBuilder removes:
-
useFactorybecomes{ provide: SEARCH_INDEX_OPTIONS, useFactory, inject }. -
useClassanduseExistingboth become a factory provider whose factory callscreateSearchIndexOptions()on the injected class. WithuseClass, the class is additionally registered as a provider of the dynamic module; withuseExistingit is not, and Nest looks it up where it already is. -
The
importsproperty is copied onto the dynamic module, so the factory’sinjecttokens resolve.
The three forms against the module above, all verified:
// useFactory: options read from ConfigService, asynchronously
SearchIndexModule.forRootAsync({
imports: [ConfigModule],
useFactory: async (config: ConfigService) => ({
url: config.getOrThrow<string>('SEARCH_URL'),
}),
inject: [ConfigService],
});
// useClass: an options factory class instantiated inside the dynamic module
@Injectable()
class SearchConfig implements SearchIndexOptionsFactory {
createSearchIndexOptions(): SearchIndexOptions {
return { url: 'http://from-class' };
}
}
SearchIndexModule.forRootAsync({ useClass: SearchConfig });
// useExisting: reuse a SearchConfig provided and exported by another module
@Module({ providers: [SearchConfig], exports: [SearchConfig] })
class ConfigHost {}
SearchIndexModule.forRootAsync({ imports: [ConfigHost], useExisting: SearchConfig });
Source: TypeORM, "Async configuration" (the same three forms in a first-party package) and Dynamic modules, "Configurable module builder".
The useFactory variant reads http://from-config from a ConfigModule loaded with an in-memory configuration, and the factory finishes before the module finishes compiling; the useClass variant reads http://from-class, and useExisting produces the same options. Omitting imports: [ConfigHost] makes the bootstrap fail, because the existing provider is not visible to the dynamic module.
Three pitfalls specific to async options:
-
Choose exactly one of
useFactory,useClassanduseExisting. They are mutually exclusive. -
A static decision cannot depend on an async value. Controllers,
globalandexportsare part of the module’s static structure and are decided when the dynamic module object is created, before any factory runs. That is whySearchIndexAsyncOptionscarriesexposeStatusas a plain property next touseFactory, not inside the options the factory returns. -
The factory runs in the dynamic module’s context. A factory that needs
ConfigServiceneedsConfigModuleinimports(orConfigModuleregistered withisGlobal); forgetting it is the most common source of "can’t resolve dependencies of the … options" errors.
ConfigurableModuleBuilder
Hand-writing register(), registerAsync(), the three async provider forms and the options token is repetitive and error-prone. ConfigurableModuleBuilder (exported by @nestjs/common) builds a module blueprint in a few lines. The following module is the minimal use:
import { ConfigurableModuleBuilder, Inject, Injectable, Module } from '@nestjs/common';
export interface PlainCoversOptions {
baseUrl: string;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<PlainCoversOptions>().build();
@Injectable()
export class PlainCoversService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) readonly options: PlainCoversOptions) {}
}
@Module({
providers: [PlainCoversService],
exports: [PlainCoversService],
})
export class PlainCoversModule extends ConfigurableModuleClass {}
build() returns four things, and the example uses two:
| Member | What it is |
|---|---|
|
A class to extend. It provides the static |
|
The injection token of the options provider. Use |
|
A type that describes the argument of |
|
The same for |
Calling PlainCoversModule.register({ baseUrl: 'https://p' }) and PlainCoversModule.registerAsync({ useFactory: async () ⇒ ({ baseUrl: 'https://q' }) }) both work. registerAsync() accepts the following object (the property list comes from the builder’s ConfigurableModuleAsyncOptions type):
{
imports?: ModuleMetadata['imports']; // modules the factory/class needs
useFactory?: (...args: any[]) => Promise<ModuleOptions> | ModuleOptions;
inject?: FactoryProvider['inject']; // tokens passed to useFactory
useClass?: Type<ConfigurableModuleOptionsFactory<ModuleOptions, FactoryClassMethodKey>>;
useExisting?: Type<ConfigurableModuleOptionsFactory<ModuleOptions, FactoryClassMethodKey>>;
provideInjectionTokensFrom?: Provider[]; // see "Nested configurable modules"
}
Because of the extends semantics, the providers listed in @Module() (PlainCoversService) appear in the final module together with the generated options provider; this is the whole reason the register() implementation can be empty.
Method names: setClassMethodName and setFactoryMethodName
Two builder methods rename what is generated:
-
setClassMethodName('forRoot')makes the class exposeforRoot()andforRootAsync()instead ofregister()andregisterAsync(). Pick the name by the conventions above. -
setFactoryMethodName('createCoverUrlsOptions')changes the method that auseClass/useExistingoptions-factory class must implement from the defaultcreate()to the given name, which is also the type the compiler checks.
Bookshelf’s cover module uses both, plus extras. The interfaces and the module definition (the file that holds the builder is conventionally called <name>.module-definition.ts, which also avoids a circular import between the module and its service):
export type CoverSize = 'small' | 'medium' | 'large';
export interface CoverUrlsModuleOptions {
baseUrl: string;
defaultSize?: CoverSize;
}
import { ConfigurableModuleBuilder } from '@nestjs/common';
import type { CoverUrlsModuleOptions } from './cover-urls.interfaces.js';
export const {
ConfigurableModuleClass,
MODULE_OPTIONS_TOKEN,
OPTIONS_TYPE,
ASYNC_OPTIONS_TYPE,
} = new ConfigurableModuleBuilder<CoverUrlsModuleOptions>()
.setClassMethodName('forRoot')
.setFactoryMethodName('createCoverUrlsOptions')
.setExtras(
{ isGlobal: false },
(definition, extras) => ({ ...definition, global: extras.isGlobal }),
)
.build();
A useClass or useExisting class must implement the configured method. With the name set above, the class is:
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type {
CoverUrlsModuleOptions,
} from './cover-urls.interfaces.js';
// Used with `useClass` / `useExisting`: it must expose the factory method
// named by setFactoryMethodName().
@Injectable()
export class CoverUrlsConfigService {
constructor(private readonly config: ConfigService) {}
createCoverUrlsOptions(): CoverUrlsModuleOptions {
return {
baseUrl: this.config.getOrThrow<string>('COVERS_BASE_URL'),
defaultSize: 'medium',
};
}
}
The generated code calls the method by name, so a class that implements the wrong one passes as any casts but fails at bootstrap. A class with create() after setFactoryMethodName('createCoverUrlsOptions') failed with optionsFactory[self.factoryClassMethodKey] is not a function; with the type ConfigurableModuleOptionsFactory<Options, 'createCoverUrlsOptions'> (exported by @nestjs/common) the compiler catches it earlier.
Extra options: setExtras
Some options configure the module, not the providers inside it. An isGlobal flag is the standard example: CoverUrlsService has no use for it, so it must not appear in MODULE_OPTIONS_TOKEN. setExtras(defaults, transform) takes the default values of the extra properties and a function that receives the generated module definition and the merged extras (consumer values over defaults) and returns the definition to use. The module definition above maps isGlobal to global:
.setExtras(
{ isGlobal: false },
(definition, extras) => ({ ...definition, global: extras.isGlobal }),
)
Source: Dynamic modules, "Extra options".
The three consequences in practice: with forRoot({ baseUrl: 'https://g.example', isGlobal: true }), a service in an unrelated module can inject CoverUrlsService without importing anything. The options object injected through MODULE_OPTIONS_TOKEN is { baseUrl: 'https://g.example' }, without isGlobal. And without the flag, the same injection fails at bootstrap with an error naming CoverUrlsService. The same extras work with the async variant: forRootAsync({ imports: [ConfigModule], useClass: CoverUrlsConfigService, isGlobal: true }). An extra is declared at call time, so it cannot come out of the async factory (the same rule as exposeStatus earlier). The transform function can do more than set global: it is the right place to add controllers, extra imports or exports based on a flag.
Extending the generated methods
To add logic (validation, defaults, extra providers) around the generated methods, override them and call super. The overrides are typed with OPTIONS_TYPE and ASYNC_OPTIONS_TYPE, which is why the definition file exports them:
import { DynamicModule, Module } from '@nestjs/common';
import { CoverUrlsService } from './cover-urls.service.js';
import {
ASYNC_OPTIONS_TYPE,
ConfigurableModuleClass,
OPTIONS_TYPE,
} from './cover-urls.module-definition.js';
function assertHttps(baseUrl: string): void {
if (!baseUrl.startsWith('https://')) {
throw new Error(`CoverUrlsModule: baseUrl must use https, got "${baseUrl}"`);
}
}
@Module({
providers: [CoverUrlsService],
exports: [CoverUrlsService],
})
export class CoverUrlsModule extends ConfigurableModuleClass {
static forRoot(options: typeof OPTIONS_TYPE): DynamicModule {
assertHttps(options.baseUrl); // fail fast, before the container is built
return super.forRoot(options);
}
static forRootAsync(options: typeof ASYNC_OPTIONS_TYPE): DynamicModule {
return super.forRootAsync(options);
}
}
forRoot() here fails fast: CoverUrlsModule.forRoot({ baseUrl: 'http://insecure' }) threw CoverUrlsModule: baseUrl must use https, got "http://insecure" while the importing module’s metadata was being evaluated, which is before any provider is created. Validation of async options belongs in the factory (or in the service constructor), because the values do not exist yet when forRootAsync() runs.
Nested configurable modules: provideInjectionTokensFrom
A module with its own options often configures another configurable module from them. The factory’s inject array then names the outer module’s options token, but the nested dynamic module has its own scope and cannot see it. provideInjectionTokensFrom solves that: give it a list of providers, and Nest copies into the nested module the ones matched by inject (and, recursively, the providers those need):
import { ConfigurableModuleBuilder, DynamicModule, Module } from '@nestjs/common';
import { CoverUrlsModule } from './cover-urls.module.js';
export interface MediaModuleOptions {
cdn: string;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN: MEDIA_OPTIONS, OPTIONS_TYPE } =
new ConfigurableModuleBuilder<MediaModuleOptions>().build();
// A module with its own options that configures a nested configurable module from them.
@Module({})
export class MediaModule extends ConfigurableModuleClass {
static register(options: typeof OPTIONS_TYPE): DynamicModule {
const media = super.register(options);
return {
...media,
imports: [
CoverUrlsModule.forRootAsync({
useFactory: (mediaOptions: MediaModuleOptions) => ({
baseUrl: `${mediaOptions.cdn}/covers`,
}),
inject: [MEDIA_OPTIONS],
// Copies the providers needed by "inject" into the nested module.
provideInjectionTokensFrom: media.providers,
}),
],
};
}
}
Source: Dynamic modules, "Configurable module builder" (the provideInjectionTokensFrom property).
MediaModule.register({ cdn: 'https://cdn.example' }) produced covers at https://cdn.example/covers/medium/9.jpg. When the same nested forRootAsync() call was written without provideInjectionTokensFrom, the bootstrap failed because MEDIA_OPTIONS was not visible in the nested module.
A complete Bookshelf example: the covers module
Putting the pieces together, Bookshelf’s CoversModule imports the cover URL module asynchronously, taking the CDN base URL from ConfigService, and a controller turns an ISBN into a URL. Nothing is hard-coded: the base URL is an environment variable (COVERS_BASE_URL), read with getOrThrow() so that a missing value stops the application at start-up rather than at the first request.
import { Inject, Injectable } from '@nestjs/common';
import type { CoverSize, CoverUrlsModuleOptions } from './cover-urls.interfaces.js';
import { MODULE_OPTIONS_TOKEN } from './cover-urls.module-definition.js';
@Injectable()
export class CoverUrlsService {
constructor(
@Inject(MODULE_OPTIONS_TOKEN)
private readonly options: CoverUrlsModuleOptions,
) {}
urlFor(isbn: string, size: CoverSize = this.options.defaultSize ?? 'medium'): string {
return `${this.options.baseUrl}/${size}/${isbn}.jpg`;
}
}
import { Controller, Get, Param, Query } from '@nestjs/common';
import type { CoverSize } from './cover-urls.interfaces.js';
import { CoverUrlsService } from './cover-urls.service.js';
@Controller('covers')
export class CoversController {
constructor(private readonly covers: CoverUrlsService) {}
@Get(':isbn')
cover(@Param('isbn') isbn: string, @Query('size') size?: CoverSize) {
return { isbn, url: this.covers.urlFor(isbn, size) };
}
}
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CoversController } from './covers.controller.js';
import { CoverUrlsModule } from './cover-urls.module.js';
@Module({
imports: [
CoverUrlsModule.forRootAsync({
imports: [ConfigModule],
useFactory: (config: ConfigService) => ({
baseUrl: config.getOrThrow<string>('COVERS_BASE_URL'),
defaultSize: 'medium',
}),
inject: [ConfigService],
}),
],
controllers: [CoversController],
})
export class CoversModule {}
Source: Dynamic modules, "Module configuration" and Configuration.
With COVERS_BASE_URL=https://covers.example the module answers:
curl 'localhost:3000/covers/9780441172719'
curl 'localhost:3000/covers/9780441172719?size=large'
{"isbn":"9780441172719","url":"https://covers.example/medium/9780441172719.jpg"}
{"isbn":"9780441172719","url":"https://covers.example/large/9780441172719.jpg"}
Without the variable the application does not start; the bootstrap failed with an error that names COVERS_BASE_URL. Replace the options in a test with overrideProvider(MODULE_OPTIONS_TOKEN), the standard way to substitute a provider (see Testing):
const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ ignoreEnvFile: true, load: [() => ({ COVERS_BASE_URL: 'https://covers.example' })] }), CoversModule] })
.overrideProvider(MODULE_OPTIONS_TOKEN)
.useValue({ baseUrl: 'https://test.example', defaultSize: 'small' })
.compile();
// GET /covers/7 -> {"isbn":"7","url":"https://test.example/small/7.jpg"}
Source: Testing, "End-to-end testing".
The relationship between the pieces, from the application down to the provider that receives the options:
Express and Fastify
Dynamic modules are resolved by the Nest container, before and independently of the HTTP platform. Controllers returned from a dynamic module are registered like any other controller, so the code above is the same on both adapters. On Fastify 5 (NestFactory.create<NestFastifyApplication>(Root, new FastifyAdapter()), app.inject({ method: 'GET', url: '/covers/42?size=small' })) the covers endpoint returns status 200 with {"isbn":"42","url":"https://covers.example/small/42.jpg"}. Under CommonJS (the Jest template) the code is identical except that the relative imports have no .js extension.
Pitfalls and design advice
-
Do not call
forRoot()in several places expecting one instance. Each call is its own configuration; configure once and re-export (or make it global), and useforFeature()for the per-feature part. -
Keep dynamic modules small and typed. Define an options interface, make options required or give defaults in one place (the service constructor, or the extras), and fail fast on bad values.
-
Do not put controllers behind async values. Use a static flag, as in
exposeStatus. -
Name things by convention.
forRootfor application-wide,registerfor per-importer,forFeaturefor slices; add theAsyncvariant when options may come from DI. -
Avoid
isGlobalby default. Global modules hide dependencies; offer the flag but leave it off. -
Never put secrets in code. Options such as credentials come from
ConfigServicein an async factory (see Configuration), never from literals inforRoot(). -
Debug resolution errors by reading the module name in the message. "available in the XModule module" tells you which module scope lacks the import.
Where the book is outdated
The book (chapter 4) presents dynamic modules through a hand-written forRoot() that returns a DynamicModule, which remains correct. Current code should go further, as this page does: forRootAsync() with useFactory, useClass and useExisting, forFeature() as a companion to a global root module, and ConfigurableModuleBuilder with setClassMethodName, setFactoryMethodName, setExtras and the generated MODULE_OPTIONS_TOKEN. Hand-writing the async provider plumbing is no longer necessary for new modules.
What changed recently
-
The official documentation chapter on dynamic modules was rewritten around a single running example and now documents the
provideInjectionTokensFromoption, theOPTIONS_TYPEandASYNC_OPTIONS_TYPEtypes, andsetExtraswith a typedisGlobalflag; the pages' examples use ES module imports with.jsextensions, as in the Nest 12 template (checked 2026-10-02). -
Nest 12 core packages are ESM-only, so module-definition files like the ones above are ESM; on CommonJS projects the Nest 12 migration relies on
require(esm)(see the Migration guide). Nothing in the dynamic module API itself changed in 12.1. -
The "Community guidelines" section of the official chapter now cites
@nestjs/http-client(a pre-1.0 package) as its example ofregister()with a per-modulebaseUrl, which matches the per-importer meaning described above.