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:

public-id/public-id.interfaces.ts
export interface PublicIdModuleOptions {
  prefix: string;
}

export const PUBLIC_ID_OPTIONS = 'PUBLIC_ID_OPTIONS';
public-id/public-id.service.ts
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()}`;
  }
}
public-id/public-id.module.ts
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.

  • PublicIdService receives the options with @Inject(PUBLIC_ID_OPTIONS), exactly like any non-class token (see Providers & Dependency Injection).

  • exports makes 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:

public-id/consumers.ts
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 lists providers: [A] and the returned object lists providers: [B], the module has both. This is what lets ConfigurableModuleClass (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

register()

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 PublicIdModule above or an HTTP client with a different base URL per consumer.

forRoot()

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 ConfigModule per application.

forFeature()

Uses the configuration set up by forRoot() but adapts part of it for the calling module (which repositories or which logger context it gets).

Feature modules that need their own slice of a root-configured resource.

registerAsync(), forRootAsync(), forFeatureAsync()

The same meaning, but the options are resolved through dependency injection (see Async options: useFactory, useClass and useExisting).

Options that come from ConfigService or another provider.

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:

search-index/search-index.interfaces.ts
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()}`;
search-index/search-index.client.ts
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()];
  }
}
search-index/search-index.module.ts
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

module (required)

The module class itself. It is the only property that is not optional.

providers

Providers that depend on the options: the options provider, services configured from it, or providers generated from a list (forFeature() above).

exports

What the importer may inject. A provider that is not exported stays private even to the module that imported the dynamic module.

imports

Other modules the dynamic module needs, including dynamic ones, such as ConfigModule for an async factory.

controllers

Controllers registered only when the options ask for them. The SearchStatusController above is registered only when exposeStatus is true: GET /search-status answers {"url":"u","indices":["books"]} with the flag on and 404 without it.

global

true makes the module global, so its exported providers can be injected anywhere without importing it (see below).

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:

ordering/ordering.ts
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],
        },
      ],
    };
  }
}

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

useFactory (+ inject)

A function, possibly async, that returns the options. inject lists the tokens passed as arguments, in order.

useClass

A class that Nest instantiates inside the dynamic module as a provider; it must expose a factory method (conventionally create()) returning the options. Its own dependencies must be resolvable inside the dynamic module, so list the modules that provide them in imports.

useExisting

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 imports).

imports

Modules whose exports the factory or the class needs (for example ConfigModule).

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:

  • useFactory becomes { provide: SEARCH_INDEX_OPTIONS, useFactory, inject }.

  • useClass and useExisting both become a factory provider whose factory calls createSearchIndexOptions() on the injected class. With useClass, the class is additionally registered as a provider of the dynamic module; with useExisting it is not, and Nest looks it up where it already is.

  • The imports property is copied onto the dynamic module, so the factory’s inject tokens 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, useClass and useExisting. They are mutually exclusive.

  • A static decision cannot depend on an async value. Controllers, global and exports are part of the module’s static structure and are decided when the dynamic module object is created, before any factory runs. That is why SearchIndexAsyncOptions carries exposeStatus as a plain property next to useFactory, not inside the options the factory returns.

  • The factory runs in the dynamic module’s context. A factory that needs ConfigService needs ConfigModule in imports (or ConfigModule registered with isGlobal); 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:

plain/plain-covers.module.ts
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

ConfigurableModuleClass

A class to extend. It provides the static register() and registerAsync() methods (the names can be changed, see Method names: setClassMethodName and setFactoryMethodName). The metadata they return is merged with the module’s own @Module() metadata.

MODULE_OPTIONS_TOKEN

The injection token of the options provider. Use @Inject(MODULE_OPTIONS_TOKEN) wherever the options are needed. The token is generated for you (it looks like CONFIGURABLE_MODULE_OPTIONS[d97c0d316136cb119fe6e], with a random suffix generated when the builder runs), which means two builder-based modules can never collide on a token; never hard-code the value.

OPTIONS_TYPE

A type that describes the argument of register(), including the extras (see Extra options: setExtras). Needed when you override the generated methods.

ASYNC_OPTIONS_TYPE

The same for registerAsync().

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 expose forRoot() and forRootAsync() instead of register() and registerAsync(). Pick the name by the conventions above.

  • setFactoryMethodName('createCoverUrlsOptions') changes the method that a useClass / useExisting options-factory class must implement from the default create() 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):

cover-urls/cover-urls.interfaces.ts
export type CoverSize = 'small' | 'medium' | 'large';

export interface CoverUrlsModuleOptions {
  baseUrl: string;
  defaultSize?: CoverSize;
}
cover-urls/cover-urls.module-definition.ts
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:

cover-urls/covers-config.service.ts
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 }),
)

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:

cover-urls/cover-urls.module.ts
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):

cover-urls/media.module.ts
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.

cover-urls/cover-urls.service.ts
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`;
  }
}
cover-urls/covers.controller.ts
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) };
  }
}
cover-urls/covers.module.ts
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 {}

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"}

The relationship between the pieces, from the application down to the provider that receives the options:

flowchart TD App["AppModule"] --> Covers["CoversModule"] Covers -->|"forRootAsync({ imports, useFactory, inject })"| Cu["CoverUrlsModule (dynamic)"] Cfg["ConfigModule / ConfigService"] -->|"getOrThrow('COVERS_BASE_URL')"| Cu Cu --> Tok["MODULE_OPTIONS_TOKEN provider"] Tok --> Svc["CoverUrlsService"] Svc -->|exported| Ctl["CoversController"] Cu -.->|"isGlobal extra: global true"| Any["any module"]

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 use forFeature() 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. forRoot for application-wide, register for per-importer, forFeature for slices; add the Async variant when options may come from DI.

  • Avoid isGlobal by default. Global modules hide dependencies; offer the flag but leave it off.

  • Never put secrets in code. Options such as credentials come from ConfigService in an async factory (see Configuration), never from literals in forRoot().

  • 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 provideInjectionTokensFrom option, the OPTIONS_TYPE and ASYNC_OPTIONS_TYPE types, and setExtras with a typed isGlobal flag; the pages' examples use ES module imports with .js extensions, 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 of register() with a per-module baseUrl, which matches the per-importer meaning described above.