Skip to content
ExponaBLOG
NESTJS · DAY 066 / 100

Dynamic modules: forRoot and forRootAsync for production

How to build configurable, reusable modules.

Expona Admin7 min read

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.

Static vs Dynamic—The Core Difference

A static module looks like this:

typescript
import { Module } from '@nestjs/common';
import { SmsService } from './sms.service';

@Module({
  providers: [SmsService],
  exports: [SmsService],
})
export class SmsModule {}

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 DynamicModule:

typescript
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],
    };
  }
}

Now when you import, you pass options:

typescript
SmsModule.forRoot({
  apiKey: 'your-key',
  provider: 'twilio',
})

SmsService receives SMS_OPTIONS via dependency injection and adapts its behavior. This is the foundation of all dynamic modules.

Using forRoot for App-Level Configuration

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.

typescript
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 {}

Every service in your application can now inject DatabaseService without re-importing the module. This is what global: true does—it registers the module globally.

forRootAsync—Loading Configuration from Providers

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.

forRootAsync accepts a configuration object with:

  • inject—array of providers to inject
  • useFactory—async or sync function that returns options
  • imports—modules needed to resolve injected providers

Here's the SMS module using ConfigService:

typescript
import { 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 {}

Now your API key is loaded from the environment at application startup, not hard-coded. The inject array ensures ConfigService is resolved first and passed to useFactory.

forRoot vs forFeature—Module Hierarchy

Many reusable modules use both patterns:

  • forRoot—called once in AppModule to set up shared infrastructure
  • forFeature—called in feature modules to register feature-specific providers

A real example: user authentication module.

typescript
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 {}

This pattern lets you configure shared auth infrastructure once, but allow each feature module to register its own role-based guards or decorators.

When to Make Modules Global

Setting 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.

Good candidates for global:

  • ConfigService (configuration)
  • Logger (logging)
  • DatabaseService (data access)
  • AuthService (authentication)

Avoid making global:

  • Business logic modules (UserService, ProductService)
  • Feature-specific services
  • Anything that's only used in a subset of modules

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:

typescript
@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],
    };
  }
}

Every service can now inject LoggerService without importing LoggingModule—because logging is truly cross-cutting.

Common Mistakes

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.

Key Takeaways

  • Dynamic modules use static factory methods (forRoot, forRootAsync) that return a DynamicModule object, allowing runtime configuration.
  • forRoot is called once at the app level to set up shared infrastructure; forFeature is called in feature modules for feature-specific registration.
  • forRootAsync accepts inject and useFactory to load options from providers like ConfigService, enabling environment-driven configuration.
  • Mark a module as global: true only for cross-cutting concerns like logging and config; keep business modules explicit.
  • Always export providers you want other modules to access, and include required imports in forRootAsync to avoid resolution errors.

ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles