AdminJS v7 in Classic NestJS Without Tears
@adminjs/nestjs v11, which did not support ESM interop out of the box — hence the dynamic import() workaround below. As of @adminjs/nestjs v12, ESM is officially supported, so if you're on v12+ you likely don't need this workaround anymore. Leaving the original post below for anyone still pinned to v11 or an older CommonJS setup.
I'm writing this blog because AdminJS decided to go with ESM in their latest version (v7) and make my life harder (a bit) because NestJS are still using CommonJS and there's no plan to support ESM.
Simply my problem was
Use require('adminjs') and you get a ERR_REQUIRE_ESM. Use normal import in TypeScript, wait for CJS to compile, again you get a ERR_REQUIRE_ESM.
But hold up isn't there an official example?
There's this repo people point to: dziraf/adminjs-v7-with-nestjs. It claims to show AdminJS v7 running smoothly with NestJS, and the AdminJS docs even link to it as an example. Cool, right? Except... have you actually tried cloning and running it? It doesn't work. There's a known open issue that's been sitting there for years: Not running · Issue #1.
The trick in one sentence
Load AdminJS and friends with dynamic import() inside an async function and keep everything else CommonJS like it always was.
Time to code
Full working example: github.com/arab0v/nestjs-adminjs-starter
Make sure to roll back all the changes from the AdminJS documentation first, then let's start.
Package Installation — create a new Nest project in the current dir if you haven't already:
nest new .
Install all the boys. Sequelize in my case (could be whatever you want — just install the ORM adapter from the AdminJS docs):
npm install sequelize adminjs @adminjs/nestjs @adminjs/sequelize @adminjs/express express-session express-formidable
Create the AdminJS ESM loader:
touch src/adminjs-loader.ts
src/adminjs-loader.ts
// This file is the only place where we touch ESM stuff
export async function loadAdminJS() {
const [adminjs, adminjsNest, sequelizeAdapter] = await Promise.all([
import('adminjs'),
import('@adminjs/nestjs'),
import('@adminjs/sequelize'),
]);
const AdminJS = adminjs.default;
const { AdminModule } = adminjsNest;
// Tell AdminJS to use Sequelize — you can use any other ORM adapter
AdminJS.registerAdapter({
Database: sequelizeAdapter.Database,
Resource: sequelizeAdapter.Resource,
});
return { AdminJS, AdminModule };
}
src/main.ts (almost unchanged)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
async function bootstrap() {
// We let AppModule do async setup (including AdminJS)
const rootModule = await AppModule.forRoot();
const app = await NestFactory.create(rootModule);
await app.listen(process.env.PORT || 3000);
}
bootstrap();
src/app.module.ts (the place where you actually use it)
import { Module } from '@nestjs/common';
import { loadAdminJS } from './adminjs-loader';
@Module({})
export class AppModule {
static async forRoot() {
const { AdminModule } = await loadAdminJS();
return {
module: AppModule,
imports: [
AdminModule.createAdmin({
adminJsOptions: {
rootPath: '/admin',
},
}),
],
};
}
}
Why this actually works well enough
- Dynamic
import()is allowed in CommonJS files - Nothing else in your project needs to become ESM
- No
"type": "module"in package.json - No tsconfig
"module": "nodenext"nightmare - No wrappers, no babel plugins, no weird loaders
- You only pay the async price once at startup
What usually goes wrong (heads up)
- Don't do
import AdminJS from 'adminjs'at the top of files — TypeScript will compile it → runtime crash - Put all AdminJS-related imports only inside
loadAdminJS()
Resources that led me to this solution
End
This is not the most beautiful solution. But it's small, contained, and lets you keep running AdminJS v7 today without rewriting half your monorepo or forcing ESM on the whole team.
Good luck.