Dynamic modules: forRoot and forRootAsync
One of the most powerful yet misunderstood features in NestJS is the ability to build dynamic modules that accept configuration at runtime. If you've only used static modules so far, you're missing out on a pattern that makes your libraries reusable, testable, and truly enterprise-grade. In this article, I'll walk you through how to design modules that adapt to different environments and use cases—the way the best community packages do it.
Why Dynamic Modules Matter
When you import a module in NestJS, you're declaring a dependency. A static module has the same providers every time—no flexibility. A dynamic module
Consider building an SMS provider module. In production, you need the real API key from AWS Secrets Manager. In testing, you want a mock. In development, maybe you're using a different provider altogether. Hard-coded providers won't cut it. Dynamic modules solve this elegantly. A static module looks like this: Every time you import this module, you get the same SmsService instance with the same dependencies. No customization. A dynamic module returns a different module object based on input. It's a static method that returns Now when you import, you pass options: SmsService receives forRoot is the convention for configuring a module once, at the application level. You call it exactly once—usually in your root AppModule—to set up shared infrastructure. Here's a real example: a database connection module. Every service in your application can now inject In real applications, you rarely hard-code secrets. You load them from ConfigService, environment variables, or external vaults at runtime. This is where forRootAsync comes in. Here's the SMS module using ConfigService: Now your API key is loaded from the environment at application startup, not hard-coded. The Many reusable modules use both patterns: A real example: user authentication module. This pattern lets you configure shared auth infrastructure once, but allow each feature module to register its own role-based guards or decorators. Setting Good candidates for global: Avoid making global: Why? Explicit imports make dependencies visible. If a payment module implicitly depends on a logging module, that's harder to reason about than explicitly importing it. Here's a logging module example that should be global: Every service can now inject LoggerService without importing LoggingModule—because logging is truly cross-cutting. 1. Calling forRoot multiple times — If you import a module with forRoot in multiple places, you'll register duplicate providers. Use global: true to prevent this, or ensure forRoot is only called once in your root module. 2. Forgetting to add imports in forRootAsync — If your useFactory depends on ConfigService, you must include ConfigModule in the imports array, or NestJS won't resolve it. 3. Not exporting providers — If SmsService is a provider but not exported, other modules can't inject it even if they import SmsModule. Always add to exports. 4. Making everything global — This creates hidden dependencies and makes testing harder. Be selective about what deserves global scope. 5. Mixing forRoot and forFeature logic — forRoot should handle app-wide setup; forFeature should handle feature-specific registration. Don't overlap them.Static vs Dynamic—The Core Difference
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';
@Module({
providers: [SmsService],
exports: [SmsService],
})
export class SmsModule {}DynamicModule:import { Module, DynamicModule } from '@nestjs/common';
import { SmsService } from './sms.service';
export interface SmsModuleOptions {
apiKey: string;
provider: 'twilio' | 'nexmo';
}
@Module({})
export class SmsModule {
static forRoot(options: SmsModuleOptions): DynamicModule {
return {
module: SmsModule,
providers: [
{
provide: 'SMS_OPTIONS',
useValue: options,
},
SmsService,
],
exports: [SmsService],
};
}
}SmsModule.forRoot({
apiKey: 'your-key',
provider: 'twilio',
})SMS_OPTIONS via dependency injection and adapts its behavior. This is the foundation of all dynamic modules.Using forRoot for App-Level Configuration
import { Module, DynamicModule } from '@nestjs/common';
import { DatabaseService } from './database.service';
export interface DatabaseOptions {
host: string;
port: number;
username: string;
password: string;
database: string;
}
@Module({})
export class DatabaseModule {
static forRoot(options: DatabaseOptions): DynamicModule {
return {
module: DatabaseModule,
global: true, // Available everywhere
providers: [
{
provide: 'DATABASE_OPTIONS',
useValue: options,
},
DatabaseService,
],
exports: [DatabaseService],
};
}
}
// In app.module.ts
@Module({
imports: [
DatabaseModule.forRoot({
host: 'localhost',
port: 5432,
username: 'admin',
password: 'secret',
database: 'myapp',
}),
],
})
export class AppModule {}DatabaseService without re-importing the module. This is what global: true does—it registers the module globally.forRootAsync—Loading Configuration from Providers
forRootAsync accepts a configuration object with:
inject—array of providers to injectuseFactory—async or sync function that returns optionsimports—modules needed to resolve injected providersimport { Module, DynamicModule } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SmsService } from './sms.service';
export interface SmsModuleOptions {
apiKey: string;
provider: 'twilio' | 'nexmo';
}
@Module({})
export class SmsModule {
static forRoot(options: SmsModuleOptions): DynamicModule {
return {
module: SmsModule,
providers: [
{
provide: 'SMS_OPTIONS',
useValue: options,
},
SmsService,
],
exports: [SmsService],
};
}
static forRootAsync(options: {
inject?: any[];
useFactory?: (
configService: ConfigService,
) => SmsModuleOptions | Promise;
imports?: any[];
}): DynamicModule {
return {
module: SmsModule,
imports: options.imports || [],
providers: [
{
provide: 'SMS_OPTIONS',
useFactory: options.useFactory,
inject: options.inject || [],
},
SmsService,
],
exports: [SmsService],
};
}
}
// In app.module.ts
@Module({
imports: [
ConfigModule.forRoot(),
SmsModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
apiKey: configService.get<string>('SMS_API_KEY'),
provider: configService.get<'twilio' | 'nexmo'>('SMS_PROVIDER'),
}),
}),
],
})
export class AppModule {}inject array ensures ConfigService is resolved first and passed to useFactory.forRoot vs forFeature—Module Hierarchy
import { Module, DynamicModule } from '@nestjs/common';
import { JwtService } from './jwt.service';
import { AuthService } from './auth.service';
export interface AuthRootOptions {
jwtSecret: string;
jwtExpiresIn: string;
}
export interface AuthFeatureOptions {
roles?: string[];
}
@Module({})
export class AuthModule {
static forRoot(options: AuthRootOptions): DynamicModule {
return {
module: AuthModule,
global: true,
providers: [
{
provide: 'AUTH_OPTIONS',
useValue: options,
},
JwtService,
AuthService,
],
exports: [AuthService],
};
}
static forFeature(options: AuthFeatureOptions): DynamicModule {
return {
module: AuthModule,
providers: [
{
provide: 'AUTH_FEATURE_OPTIONS',
useValue: options,
},
],
exports: [AuthService],
};
}
}
// In app.module.ts
@Module({
imports: [
AuthModule.forRoot({
jwtSecret: 'secret-key',
jwtExpiresIn: '24h',
}),
],
})
export class AppModule {}
// In users.module.ts (feature module)
@Module({
imports: [
AuthModule.forFeature({
roles: ['user', 'admin'],
}),
],
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}When to Make Modules Global
global: true on a module means every service can inject its providers without explicitly importing the module. This is powerful but should be used carefully.
@Module({})
export class LoggingModule {
static forRoot(options: { level: 'debug' | 'info' | 'warn' | 'error' }): DynamicModule {
return {
module: LoggingModule,
global: true,
providers: [
{
provide: 'LOG_LEVEL',
useValue: options.level,
},
LoggerService,
],
exports: [LoggerService],
};
}
}Common Mistakes
Key Takeaways






Comments
Loading comments…