Skip to content
NestJS ns fundamentals 4 min read

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 @Moduleimports, providers, controllers, exports — is valid in the returned object. The properties merge with the (empty) decorator metadata.

PropertyTypePurpose
moduleTypeRequired. The host class whose metadata is being defined.
providersProvider[]Providers instantiated and scoped to this module.
exports(Provider | string | symbol)[]Providers made available to importers.
importsModuleImport[]Other modules this one depends on.
globalbooleanWhen 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/forFeature signal a module configured for the whole app, while register/registerAsync signal 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/forRootAsync for app-wide setup, forFeature for per-feature additions, register/registerAsync for 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 on forFeature() 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.
Last updated June 14, 2026
Was this helpful?