As JavaScript continues to evolve, more libraries are adopting ECMAScript modules (ESM) as the standard for module definition. However, integrating ESM-only packages into projects like NestJS—which traditionally relies on CommonJS—can be challenging. This guide will show you how to load pure ESM modules dynamically in a NestJS application using an example with the libraries remark-parse
, unified
, and remark-frontmatter
.
By the end of this tutorial, you will understand how to:
import()
.NestJS projects are often initialized with TypeScript configurations that favor CommonJS for compatibility. ESM-only packages cannot be directly require()
'd in such setups, leading to errors. While migrating the entire project to ESM is an option, it can introduce significant overhead, especially in larger applications. The approach we’ll take—loading ESM modules dynamically—lets us work around this limitation while retaining most of the project's existing setup.
We will demonstrate how to integrate ESM modules by dynamically loading them at runtime and providing them to the application as dependencies. Our example will use the following libraries:
remark-parse
: A Markdown parser.unified
: A framework for processing content.remark-frontmatter
: A plugin to parse frontmatter (e.g., YAML metadata) in Markdown.These tools will be used to process a Markdown string containing frontmatter and convert it into an abstract syntax tree (AST).
This article assumes you have an existing NestJS project to work with. Let's get started by installing the example ESM packages from NPM:
$ npm i unified remark-parse remark-frontmatter
To use ESM in a Node.js project, update your tsconfig.json
to support ESM. Set the module
property to Node16
(or NodeNext
if you're targeting experimental features):
{
"compilerOptions": {
"module": "Node16", // <- Change to Node16 instead of CommonJS
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": false,
"noImplicitAny": false,
"strictBindCallApply": false,
"forceConsistentCasingInFileNames": false,
"noFallthroughCasesInSwitch": false
}
}
This ensures that TypeScript understands how to handle both CommonJS and ESM modules. Specifically, it prevents the following error being thrown when starting our NestJS app:
ERROR [ExceptionHandler] require() of ES Module /path/to/file.js from /path/to/otherFile.js not supported.
Another extremely important step is to ensure your Typescript version is v5.3 or higher. This is because Typescript v5.3 introduced resolution-mode support in all module modes which will prevent our code from displaying an error when we dynamically import the ESM packages.
Next, create a utility file (loadEsm.ts
) to dynamically load the ESM dependencies:
export type RemarkParse = Awaited<
ReturnType<typeof loadDependencies>
>['remarkParse'];
export type Unified = Awaited<ReturnType<typeof loadDependencies>>['unified'];
export type RemarkFrontmatter = Awaited<
ReturnType<typeof loadDependencies>
>['remarkFrontmatter'];
export async function loadDependencies() {
const remarkParse = await import('remark-parse');
const unified = await import('unified');
const remarkFrontmatter = await import('remark-frontmatter');
const res = {
remarkParse: remarkParse.default,
unified: unified.unified,
remarkFrontmatter: remarkFrontmatter.default,
};
return res;
}
This file:
import()
.Add constants to represent the providers for your dependencies. For example, create a constants.ts
file:
export const REMARK_PARSE = 'REMARK_PARSE';
export const UNIFIED = 'UNIFIED';
export const REMARK_FRONTMATTER = 'REMARK_FRONTMATTER';
In app.module.ts
, use asynchronous factories to load the ESM dependencies and provide them as injectable tokens:
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { loadDependencies } from './loadEsm';
import { REMARK_FRONTMATTER, REMARK_PARSE, UNIFIED } from './constants';
@Module({
controllers: [AppController],
providers: [
{
provide: REMARK_PARSE,
useFactory: async () => {
const dependencies = await loadDependencies();
return dependencies.remarkParse;
},
},
{
provide: UNIFIED,
useFactory: async () => {
const dependencies = await loadDependencies();
return dependencies.unified;
},
},
{
provide: REMARK_FRONTMATTER,
useFactory: async () => {
const dependencies = await loadDependencies();
return dependencies.remarkFrontmatter;
},
},
AppService,
],
})
export class AppModule {}
This approach uses useFactory
to handle asynchronous loading and provides each module as a dependency.
Finally, inject the ESM dependencies into a service to use them:
import { Inject, Injectable } from '@nestjs/common';
import { REMARK_FRONTMATTER, REMARK_PARSE, UNIFIED } from './constants';
import { RemarkFrontmatter, RemarkParse, Unified } from './loadEsm';
@Injectable()
export class AppService {
constructor(
@Inject(REMARK_PARSE) private readonly remarkParse: RemarkParse,
@Inject(UNIFIED) private readonly unified: Unified,
@Inject(REMARK_FRONTMATTER)
private readonly remarkFrontmatter: RemarkFrontmatter,
) {}
getHello(): string {
const processor = this.unified()
.use(this.remarkParse)
.use(this.remarkFrontmatter, ['yaml']);
const tree = processor.parse(`
---
title: Hello World
---
# Hello World
This is my Markdown file
`);
if (!tree) {
throw new Error('Tree is missing');
}
return JSON.stringify(tree);
}
}
In this code:
remarkParse
, unified
, and remarkFrontmatter
) are used to process a Markdown string with frontmatter.By dynamically loading ESM modules and using asynchronous custom providers, you can seamlessly integrate ESM-only libraries into your NestJS project without needing a full migration to ESM. This approach is flexible, type-safe, and ensures compatibility with your existing codebase.
With this setup, you can now leverage the growing ecosystem of ESM packages in your NestJS applications. Happy coding!