Dynamic Modules
Most modules in NestJS are static: you list their providers and exports once, and every consumer gets the same configuration. Dynamic modules break that rule. They are modules that build their own metadata at runtime through a static factory method, so the importing module can pass options that shape what providers are registered. This is the pattern behind ConfigModule.forRoot(), TypeOrmModule.forRoot(), and JwtModule.register() — and it is exactly how you should design reusable, configurable libraries of your own.
The DynamicModule return type
A dynamic module is just a regular @Module-decorated class that also exposes a static method returning a DynamicModule object. That object is the same shape as the metadata you pass to @Module, plus a required module property pointing back at the host class.
import { DynamicModule, Module } from '@nestjs/common';
@Module({})
export class ConfigModule {
static forRoot(): DynamicModule {
return {
module: ConfigModule,
providers: [ConfigService],
exports: [ConfigService],
};
}
}
The @Module({}) decorator can be left empty because the real metadata is produced at call time. Anything you can put in @Module — imports, providers, controllers, exports — is valid in the returned object. The properties merge with the (empty) decorator metadata.
| Property | Type | Purpose |
|---|---|---|
module | Type | Required. The host class whose metadata is being defined. |
providers | Provider[] | Providers instantiated and scoped to this module. |
exports | (Provider | string | symbol)[] | Providers made available to importers. |
imports | ModuleImport[] | Other modules this one depends on. |
global | boolean | When true, registers the module globally. |
Passing options with forRoot
The point of a dynamic module is configuration. You accept an options object, turn it into a value provider, and let your service inject it. Defining an injection token keeps the wiring type-safe.
// config.constants.ts
export const CONFIG_OPTIONS = 'CONFIG_OPTIONS';
export interface ConfigModuleOptions {
folder: string;
}
// config.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { CONFIG_OPTIONS, ConfigModuleOptions } from './config.constants';
import { ConfigService } from './config.service';
@Module({})
export class ConfigModule {
static forRoot(options: ConfigModuleOptions): DynamicModule {
return {
module: ConfigModule,
providers: [
{ provide: CONFIG_OPTIONS, useValue: options },
ConfigService,
],
exports: [ConfigService],
};
}
}
// config.service.ts
import { Inject, Injectable } from '@nestjs/common';
import * as fs from 'fs';
import * as path from 'path';
import { CONFIG_OPTIONS, ConfigModuleOptions } from './config.constants';
@Injectable()
export class ConfigService {
private readonly env: Record<string, string>;
constructor(@Inject(CONFIG_OPTIONS) options: ConfigModuleOptions) {
const filePath = path.resolve(process.cwd(), options.folder, '.env');
const raw = fs.readFileSync(filePath, 'utf-8');
this.env = Object.fromEntries(
raw
.split('\n')
.filter(Boolean)
.map((line) => line.split('=') as [string, string]),
);
}
get(key: string): string | undefined {
return this.env[key];
}
}
Consuming it is then a one-liner in the root module:
// app.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule } from './config/config.module';
@Module({
imports: [ConfigModule.forRoot({ folder: './config' })],
})
export class AppModule {}
By convention forRoot() configures a module once for the whole application, while forFeature() configures per-feature behaviour against that already-established root.
forFeature for per-feature setup
forFeature() is used when a module is configured globally once, but individual feature modules need to register additional scoped resources — entities, repositories, or queue names, for example. It returns a DynamicModule too, but typically only adds providers rather than re-establishing the core service.
@Module({})
export class StorageModule {
static forFeature(bucket: string): DynamicModule {
const bucketProvider = {
provide: `BUCKET_${bucket}`,
useFactory: (client: StorageClient) => client.bucket(bucket),
inject: [StorageClient],
};
return {
module: StorageModule,
providers: [bucketProvider],
exports: [bucketProvider],
};
}
}
Naming matters:
forRoot/forFeaturesignal a module configured for the whole app, whileregister/registerAsyncsignal a module configured per-import with no global root. Stick to these conventions so consumers know what to expect.
Async configuration with forRootAsync
Hardcoding options works for static values, but real configuration often comes from another service — a ConfigService, a secrets manager, or an async lookup. The async variant accepts a useFactory (plus inject) so options are resolved through the DI container after dependencies are ready.
import { DynamicModule, Module, Provider } from '@nestjs/common';
import { CONFIG_OPTIONS, ConfigModuleOptions } from './config.constants';
import { ConfigService } from './config.service';
interface ConfigModuleAsyncOptions {
imports?: any[];
inject?: any[];
useFactory: (...args: any[]) =>
| Promise<ConfigModuleOptions>
| ConfigModuleOptions;
}
@Module({})
export class ConfigModule {
static forRootAsync(options: ConfigModuleAsyncOptions): DynamicModule {
const optionsProvider: Provider = {
provide: CONFIG_OPTIONS,
useFactory: options.useFactory,
inject: options.inject ?? [],
};
return {
module: ConfigModule,
imports: options.imports ?? [],
providers: [optionsProvider, ConfigService],
exports: [ConfigService],
};
}
}
Now the importing module can derive options from another provider:
@Module({
imports: [
ConfigModule.forRootAsync({
imports: [SecretsModule],
inject: [SecretsService],
useFactory: async (secrets: SecretsService) => ({
folder: await secrets.resolve('config-path'),
}),
}),
],
})
export class AppModule {}
Output:
[Nest] LOG [InstanceLoader] SecretsModule dependencies initialized
[Nest] LOG [InstanceLoader] ConfigModule dependencies initialized
[Nest] LOG [NestApplication] Nest application successfully started
The factory runs only after SecretsModule is initialized and SecretsService is available, guaranteeing the async value is ready before ConfigService is constructed.
Best Practices
- Use a dedicated injection token (string, symbol, or
InjectionToken) for options instead of injecting a raw class — it keeps consumers decoupled from your internals. - Follow naming conventions:
forRoot/forRootAsyncfor app-wide setup,forFeaturefor per-feature additions,register/registerAsyncfor per-import config without a singleton root. - Always provide an async variant (
forRootAsync) for anything users may want to configure from another provider or environment source. - Keep
forRoot()idempotent and call it exactly once; rely onforFeature()for repeated, scoped registration. - Validate options inside the factory and fail fast with a clear error rather than letting a misconfiguration surface later.
- Export only what consumers need; leave option providers and internal helpers unexported.