Skip to main content

What are Dynamic Modules in NestJS and when to use them?

Dynamic module in NestJS is a module that returns a customized DynamicModule object from a static method like forRoot(), letting you pass configuration at import time instead of hardcoding it.

Theory

TL;DR

  • Think of a static module as a pre-built LEGO set. A dynamic one is a factory kit: you send instructions, you get a set tailored to your build.
  • Static modules export a fixed @Module({}) decorator with the same providers every time. Dynamic modules generate that metadata on demand based on the options you pass in.
  • Use dynamic if config varies per app or environment (DB credentials, JWT secret). Stick to static for shared logic that never needs configuration.
  • Standard naming: forRoot() for global one-time setup, forFeature() for per-module customization, forRootAsync() when options come from another service.
  • The return object must always include a module key. Forgetting it breaks the DI graph in a way that is surprisingly hard to trace.

Quick example

typescript
// Static: always the same providers @Module({ providers: [LoggerService] }) export class LoggerModule {} // Dynamic: providers change based on what you pass in @Module({}) export class LoggerModule { static forRoot(level: 'error' | 'debug' | 'verbose'): DynamicModule { return { module: LoggerModule, providers: [ { provide: 'LOG_LEVEL', useValue: level }, // value injected from outside LoggerService, ], exports: [LoggerService], }; } } // Usage @Module({ imports: [LoggerModule.forRoot('debug')], }) export class AppModule {}

LoggerModule.forRoot('debug') runs at import time and returns a plain object that NestJS treats as module metadata. No magic. The LOG_LEVEL token is now available for injection anywhere in the module.

Static vs dynamic modules

Static modules export a fixed @Module({}) decorator with hardcoded providers. Every import gets the identical setup, which works fine for a shared utility service with no configuration. Dynamic modules call a static method that returns a DynamicModule object, injecting options at registration time. This lets you create different configurations of the same module in different parts of the app, which solves shared-state problems in multi-tenant or configurable libraries.

One practical consequence: with static modules, there is no clean place to put DB credentials. You either hardcode them or reach for globals. Dynamic modules give you a proper API for that.

When to use

  • Config differs per app or environment: forRoot() with an options object (host, port, credentials)
  • Options come from another service like ConfigService: forRootAsync() with a factory function and an inject array
  • Per-module entity or queue setup: forFeature(), for example TypeOrmModule.forFeature([User])
  • Shared service with no configuration needed: a static module is enough
  • Testing with mock configuration: dynamic modules let you pass fake options without touching source code

How NestJS processes dynamic modules

When NestModuleLoader.create() runs during bootstrap, it scans every entry in imports[]. If the entry is a function call like LoggerModule.forRoot('debug'), that call executes synchronously and returns a plain DynamicModule object. NestJS then merges that object's providers, imports, and exports into the global metadata graph stored in ModulesContainer. All of this happens before any providers resolve. After bootstrap, there is no additional overhead as providers resolve lazily through the DI container.

forRootAsync() works differently. NestJS registers the useFactory provider normally but delays calling the factory until all dependencies listed in inject are available.

Common mistakes

Forgetting module in the return object

typescript
// Wrong static forRoot(opts: Options) { return { providers: [{ provide: 'CONFIG', useValue: opts }] }; // no module key } // Right static forRoot(opts: Options): DynamicModule { return { module: MyModule, // required providers: [{ provide: 'CONFIG', useValue: opts }], exports: ['CONFIG'], }; }

NestJS cannot register metadata without the module key. The error message ("Nest can't resolve dependencies") typically points somewhere else, which makes this one of those bugs that wastes 20 minutes because the stack trace never points directly at the cause.

Mutating the options object

typescript
// Wrong static forRoot(opts: Options) { opts.host = 'overridden'; // mutates the caller's object return { module: MyModule, providers: [{ provide: 'CONFIG', useValue: opts }] }; } // Right static forRoot(opts: Options): DynamicModule { return { module: MyModule, providers: [{ provide: 'CONFIG', useValue: { ...opts } }], // spread or deep clone }; }

If two parts of the app call forRoot() with different options, mutation causes them to interfere with each other. Spread or deep-clone the options before using them.

Putting exports in the class decorator instead of the return object

typescript
// Wrong @Module({ exports: [Service] }) // fires even without forRoot being called export class MyModule { static forRoot(): DynamicModule { return { module: MyModule }; // no exports here } } // Right @Module({}) export class MyModule { static forRoot(): DynamicModule { return { module: MyModule, providers: [Service], exports: [Service] }; } }

The class-level decorator runs unconditionally. Modules that never call forRoot() still get those exports, which leads to subtle bugs in multi-tenant setups.

Using forRoot inside feature modules

forRoot is for global root configuration, called once in AppModule. Calling it inside a feature module creates duplicate providers in the DI container. Feature modules should use forFeature() instead.

Forgetting inject in forRootAsync

typescript
// Wrong - config is undefined at runtime TypeOrmModule.forRootAsync({ useFactory: (config: ConfigService) => config.getDbOptions(), // inject is missing }); // Right TypeOrmModule.forRootAsync({ useFactory: (config: ConfigService) => config.getDbOptions(), inject: [ConfigService], // required, must match factory parameter order });

This is the most common mistake among developers new to async configuration. The factory receives undefined arguments and throws at runtime. TypeScript won't catch it at compile time.

Real-world usage

  • @nestjs/typeorm: TypeOrmModule.forRoot({ host, entities }) in AppModule, TypeOrmModule.forFeature([User]) in each feature module
  • @nestjs/jwt: JwtModule.register({ secret }) for auth token signing
  • @nestjs/bull: BullModule.forRoot({ redis }) for queue setup, BullModule.forFeature(Processor) per job type
  • @nestjs/config: ConfigModule.forRoot() loads .env globally
  • @nestjs/mongoose: MongooseModule.forRoot('mongodb://...'), then MongooseModule.forFeature([Cat]) per module

Every major NestJS integration library follows this pattern. Once you understand how dynamic modules work, reading the source of any of these becomes straightforward.

Follow-up questions

Q: What is the return type of forRoot() and what fields does it accept?
A: It returns the DynamicModule interface: { module: Type<any>, providers?: Provider[], imports?: any[], exports?: any[], global?: boolean }. The module field is the only required one.

Q: What is the difference between forRoot() and forRootAsync()?
A: forRoot() takes synchronous options directly as arguments. forRootAsync() takes a useFactory function with an inject array, so NestJS resolves the listed dependencies first and passes them into the factory.

Q: How does NestJS handle two forRoot() calls for the same module?
A: It merges providers into a single module instance. Set global: true for a shared global configuration. If you need isolated instances per module, use forFeature() instead.

Q: Can a dynamic module include other dynamic modules in its imports?
A: Yes. You can nest forFeature() calls inside a forRoot() return's imports array. This is exactly how TypeORM chains entity repositories inside the root connection module.

Q: (Senior) What happens if forRoot() throws during bootstrap, and how do you debug it?
A: The app fails to start with a bootstrap exception. Run nest start --debug and trace the stack from ModulesContainer.register(). Add a console.log before the return statement in your dynamic method to inspect what object NestJS actually receives. Circular imports between modules that both use forRootAsync are another common cause of silent bootstrap failures.

Q: When would you skip dynamic modules entirely and use a plain factory provider?
A: For one-off setups that are not meant to be reused across projects. If you have one app and one DB, a useFactory provider directly in AppModule is simpler and completely valid. Dynamic modules add API design overhead that only pays off when the module is shared or reused.

Examples

Basic: configurable logger module

typescript
import { Module, DynamicModule } from '@nestjs/common'; type LogLevel = 'error' | 'debug' | 'verbose'; @Module({}) export class LoggerModule { static forRoot(level: LogLevel): DynamicModule { return { module: LoggerModule, providers: [ { provide: 'LOG_LEVEL', useValue: level }, LoggerService, // uses @Inject('LOG_LEVEL') in its constructor ], exports: [LoggerService], }; } } @Module({ imports: [LoggerModule.forRoot('debug')], }) export class AppModule {}

LoggerService receives the 'debug' level through its constructor via @Inject('LOG_LEVEL'). Change the argument to 'error' in production without touching any service code.

Intermediate: database module with async config

typescript
import { Module, DynamicModule } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @Module({}) export class DatabaseModule { static forRootAsync(options: { imports?: any[]; useFactory: (...args: any[]) => Promise<{ host: string; port: number }>; inject?: any[]; }): DynamicModule { return { module: DatabaseModule, imports: options.imports || [], providers: [ { provide: 'DB_CONFIG', useFactory: options.useFactory, inject: options.inject || [], }, DatabaseService, ], exports: [DatabaseService], }; } } // Usage in AppModule @Module({ imports: [ ConfigModule.forRoot(), DatabaseModule.forRootAsync({ imports: [ConfigModule], useFactory: (config: ConfigService) => ({ host: config.get<string>('DB_HOST'), port: config.get<number>('DB_PORT'), }), inject: [ConfigService], // must match useFactory parameter order }), ], }) export class AppModule {}

DB_CONFIG resolves only after ConfigService is ready. Remove the inject array and config becomes undefined at runtime. TypeScript does not catch this because the factory signature accepts any[].

Advanced: forFeature for per-module storage buckets

typescript
@Module({}) export class StorageModule { static forRoot(options: { provider: string; region: string }): DynamicModule { return { module: StorageModule, global: true, // one shared StorageService across the app providers: [ { provide: 'STORAGE_OPTIONS', useValue: { ...options } }, StorageService, ], exports: [StorageService], }; } static forFeature(bucket: string): DynamicModule { return { module: StorageModule, providers: [ { provide: `STORAGE_BUCKET_${bucket.toUpperCase()}`, useFactory: (storage: StorageService) => storage.getBucket(bucket), inject: [StorageService], // pulls from global StorageService }, ], exports: [`STORAGE_BUCKET_${bucket.toUpperCase()}`], }; } } // Each feature module gets its own scoped bucket client @Module({ imports: [StorageModule.forFeature('avatars')], }) export class UsersModule {} @Module({ imports: [StorageModule.forFeature('documents')], }) export class DocumentsModule {}

StorageService is registered globally once via forRoot. Each forFeature call creates a scoped provider that wraps the shared service with a specific bucket name. The underlying connection is not duplicated.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?