Building reusable NestJS modules means writing forRoot and forRootAsync methods over and over, managing tokens, and keeping your types consistent across dynamic configurations. The ConfigurableModuleBuilder, introduced in NestJS 9, eliminates that repetition entirely. This guide walks you through using ConfigurableModuleBuilder to generate the boilerplate you'd normally hand-code, keeping your shared libraries lean and your options strongly typed.
What ConfigurableModuleBuilder does
ConfigurableModuleBuilder is a utility class that generates forRoot and forRootAsync static methods for your dynamic modules. Instead of manually creating these methods, managing injection tokens, and ensuring type safety across your module exports, you define your options shape once and let the builder handle the rest.
When you're shipping a shared library—whether it's a custom database wrapper, a logging adapter, or a third-party service client—you want consumers to inject configuration without needing to understand your internal token names. The builder generates everything: the token, the factory functions, and the method signatures.
Consider a hypothetical analytics module that tracks user events. Without the builder, you'd write:
export const ANALYTICS_OPTIONS = Symbol('ANALYTICS_OPTIONS');
@Module({})
export class AnalyticsModule {
static forRoot(options: AnalyticsOptions): DynamicModule {
return {
module: AnalyticsModule,
providers: [
{ provide: ANALYTICS_OPTIONS, useValue: options },
AnalyticsService,
],
exports: [AnalyticsService],
};
}
static forRootAsync(options: AsyncModuleOptions<AnalyticsOptions>): DynamicModule {
// ... lots of provider setup
}
}With ConfigurableModuleBuilder, you eliminate that scaffolding and focus on your options shape.
Generating forRoot and forRootAsync
The builder generates both static methods from a single definition. Start by creating your options interface, then instantiate the builder with it.
import { ConfigurableModuleBuilder } from '@nestjs/common';
export interface AnalyticsOptions {
apiKey: string;
endpoint: string;
batchSize?: number;
timeout?: number;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<AnalyticsOptions>()
.setClassMethodName('forRoot')
.setFactoryMethodName('createConfig')
.build();
@Module({})
export class AnalyticsModule extends ConfigurableModuleClass {}That's it. Your module now has forRoot, forRootAsync, and a properly typed token. Consumers use it like any other dynamic module:
@Module({
imports: [
AnalyticsModule.forRoot({
apiKey: 'abc123',
endpoint: 'https://analytics.example.com',
batchSize: 100,
}),
],
})
export class AppModule {}Or with async configuration:
@Module({
imports: [
AnalyticsModule.forRootAsync({
useFactory: async (configService: ConfigService) => ({
apiKey: configService.get('ANALYTICS_KEY'),
endpoint: configService.get('ANALYTICS_ENDPOINT'),
batchSize: 100,
}),
inject: [ConfigService],
}),
],
})
export class AppModule {}The builder automatically wires up the token, the provider, and the exports. No manual DynamicModule construction needed.
Typed options injection with MODULE_OPTIONS_TOKEN
MODULE_OPTIONS_TOKEN is the heart of type safety. It's the injection token that the builder creates and that your services consume. When you inject it, TypeScript knows exactly what shape the configuration will be.
import { Inject } from '@nestjs/common';
@Injectable()
export class AnalyticsService {
constructor(@Inject(MODULE_OPTIONS_TOKEN) private readonly options: AnalyticsOptions) {}
track(event: string, data: Record<string, any>) {
// options is fully typed; TypeScript knows about apiKey, endpoint, etc.
console.log(`Sending to ${this.options.endpoint}`);
}
}
@Module({
providers: [AnalyticsService],
exports: [AnalyticsService],
})
export class AnalyticsModule extends ConfigurableModuleClass {}<p></p>
<p>The token is exported automatically. Any service in your module can inject it and get type hints for free. You don't need to export a custom token symbol or worry about naming collisions.</p>
<h2 id="supporting-usefactory-useclass-and-useexisting">Supporting useFactory, useClass, and useExisting</h2>
<p>The async methods support all three standard NestJS provider patterns. <code>useFactory</code> is the most common: you pass a function that returns your options, and you can inject dependencies.</p>
<pre><code class="language-typescript">// useFactory
AnalyticsModule.forRootAsync({
useFactory: (config: ConfigService) => ({
apiKey: config.get('ANALYTICS_KEY'),
endpoint: config.get('ANALYTICS_ENDPOINT'),
}),
inject: [ConfigService],
})
// useClass: instantiate a class that provides options
AnalyticsModule.forRootAsync({
useClass: AnalyticsConfigService,
})
// useExisting: reuse an existing provider
AnalyticsModule.forRootAsync({
useExisting: AnalyticsConfigService,
})useClass tells NestJS to instantiate a new instance of AnalyticsConfigService (if it's not already a provider). That class should implement an interface or have a method that returns AnalyticsOptions. NestJS calls its create() method by default, but you can customize that with setFactoryMethodName:
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<AnalyticsOptions>()
.setClassMethodName('forRoot')
.setFactoryMethodName('buildConfig') // looks for buildConfig() instead of create()
.build();
@Injectable()
export class AnalyticsConfigService {
buildConfig(): AnalyticsOptions {
return {
apiKey: process.env.ANALYTICS_KEY,
endpoint: process.env.ANALYTICS_ENDPOINT,
};
}
}useExisting is useful when you want to reuse a provider from the parent module without creating a new instance. It keeps your dependency graph cleaner.
Ideal for internal shared libraries
Shared libraries—the code you extract from a monorepo or publish internally—are where ConfigurableModuleBuilder shines. These modules often need flexible configuration, and you don't want to force consumers to understand your token names or provide boilerplate.
Imagine a team-wide logging library. You define the options once:
// @myorg/logging
export interface LoggingOptions {
level: 'debug' | 'info' | 'warn' | 'error';
format: 'json' | 'text';
service: string;
}
export const { ConfigurableModuleClass, MODULE_OPTIONS_TOKEN } =
new ConfigurableModuleBuilder<LoggingOptions>()
.setClassMethodName('forRoot')
.setFactoryMethodName('createOptions')
.build();
@Module({})
export class LoggingModule extends ConfigurableModuleClass {
// consumers import and use LoggingModule.forRoot(...) or forRootAsync(...)
}Across your microservices, you use it consistently without reimplementing boilerplate:
// service-a
LoggingModule.forRoot({ level: 'info', format: 'json', service: 'service-a' })
// service-b
LoggingModule.forRootAsync({
useFactory: () => ({
level: process.env.LOG_LEVEL || 'info',
format: 'json',
service: 'service-b',
}),
})Every service uses the same API, types are enforced, and you never expose internal token symbols to consumers.
Common mistakes
- Forgetting to export MODULE_OPTIONS_TOKEN: If your library consumers need to inject the options in their own services, export the token. Add
export { MODULE_OPTIONS_TOKEN } from './path-to-module'in your barrel file. - Not extending ConfigurableModuleClass: Your module must inherit from the generated class for the methods to be available. This is a common copy-paste error.
- Mixing manual and generated methods: Once you use the builder, don't add your own
forRootorforRootAsyncmethods. Let the builder own them, or you'll confuse consumers and create maintenance headaches. - Ignoring partial options: If some options should be optional, mark them as such in your interface (
timeout?: number). The builder respects your type definition. - Providing MODULE_OPTIONS_TOKEN manually: The builder handles provider setup. If you also manually provide the token, you'll get provider duplication errors. Trust the builder.
Key takeaways
ConfigurableModuleBuildergeneratesforRootandforRootAsyncautomatically, removing dynamic module boilerplate.- Define your options interface once; the builder handles token creation, type safety, and provider setup.
MODULE_OPTIONS_TOKENis the injection token your services use—it's properly typed by TypeScript.- Support
useFactory,useClass, anduseExistingpatterns out of the box. - Shared libraries benefit most: one clear API, consistent configuration across consumers, no token naming confusion.
- Always extend
ConfigurableModuleClassin your module and export the token if consumers need it.






Comments
Loading comments…