Import a directory of files in a loop: TS1343, require()
Two working loaders for a folder of Express route files, one synchronous and one async, plus the startup-order bug that forEach with import() hides. Reproduced in Docker.
TL;DR#
A loop that loads every file in src/routes hits two walls in a TypeScript project compiled as CommonJS. import.meta does not compile at all (TS1343), and the import() you write turns back into require(), which an ES module route file refuses on older Node releases. Pick one module system for the loader. Synchronous: remove "type": "module", write the route files in TypeScript, and require() each file from __dirname in a for loop before app.listen(). Asynchronous: switch to "module": "nodenext", read the folder from import.meta.dirname, and await import() each file in a for...of loop, then await the loader before listen(). Both loaders below were reproduced in Docker on 21 October 2026.
The two errors from the question#
The Stack Overflow question describes a mock Express server that should mount one router per file in src/routes. It runs ts-node-dev src/server.ts inside a node:alpine container, with "type": "module" in package.json, ts-node-dev ^1.1.8, typescript ^4.6.3, and a tsconfig.json set to "target": "es5" and "module": "commonjs". The route files are plain .js files written with import and export default.
The first loader called createRequire(import.meta.url) and never compiled. Rebuilt with the same versions in node:18-alpine, it prints:
[INFO] 23:16:24 ts-node-dev ver. 1.1.8 (using ts-node ver. 9.1.1, typescript ver. 4.6.3)
Compilation error in /app/src/routes/index.ts
[ERROR] 23:16:33 ⨯ Unable to compile TypeScript:
src/routes/index.ts(6,33): error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node12', or 'nodenext'.The second loader dropped import.meta and called import() inside paths.forEach. It compiles, the server starts, and then the process dies:
listening on 3000
Error [ERR_REQUIRE_ESM]: require() of ES Module /app/src/routes/module-streams.js from /app/src/routes/index.ts not supported.
Instead change the require of module-streams.js in /app/src/routes/index.ts to a dynamic import() which is available in all CommonJS modules.
at Object.nodeDevHook [as .js] (/app/node_modules/ts-node-dev/lib/hook.js:63:13)
at /app/src/routes/index.ts:40:81Two details in that output matter for a directory loader, and both come back in the fixes: the message asks for the import() the code already uses, and listening on 3000 is printed before any route file has loaded.
Why a folder loop breaks under module commonjs#
TS1343. The Node.js documentation says import.meta "is only supported in ES modules". TypeScript enforces this at compile time, so with "module": "commonjs" the file is rejected before it runs. The list in the message is misleading on this exact setup: TypeScript 4.6.3 refuses the nodenext value it suggests, as a plain tsc run in the same container shows:
### same file, --module nodenext (typescript 4.6.3)
error TS4124: Compiler option 'module' of value 'nodenext' is unstable. Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.Current compilers word the list differently, so the message you paste may not match the question's. TypeScript 7.0.2, run on a one-line file that reads import.meta.url, prints:
meta.ts(1,21): error TS1343: The 'import.meta' meta-property is only allowed when the '--module' option is 'es2020', 'es2022', 'esnext', 'system', 'node16', 'node18', 'node20', or 'nodenext'.Either way the fix is not a value from that list on its own: import.meta needs the loader to be an ES module, which fix 2 below sets up end to end.
ERR_REQUIRE_ESM. Node supports import() "in both CommonJS and ES modules", in the words of the Node.js ESM documentation. The problem is that the import() never reaches Node. Compiling the loader with the question's settings shows what ts-node-dev actually runs, and the line number matches the index.ts:40 in the stack trace:
### tsc 4.6.3, module commonjs, target es5: compiled import() line
40: var test = Promise.resolve().then(function () { return __importStar(require("./".concat(filePath))); });That require() then targets module-streams.js, which "type": "module" makes an ES module, so Node 18 refuses it. The page on return old(m, filename) ERR_REQUIRE_ESM in ts-node walks through this same rewrite for an npm package, and shows which Node releases stop refusing. This page stays on what is specific to loading a whole folder.
The startup order. forEach calls its callback once per file and ignores what the callback returns, so the promises from import() are dropped. routes() returns at once, app.listen() runs, and the files load later. In the log above, listening on 3000 comes first and the crash second. The order stays wrong when the import succeeds: on Node 22.12.0, where the same project no longer crashes, the reproduction still prints listening on 3000 before Node reports loading module-streams.js, so the server accepts requests before its routes exist.
Check which module system each route file uses#
If your loop fails with a different module message (Cannot use import statement outside a module or exports is not defined in ES module scope), paste it here to see which side of the CommonJS / ES module boundary each file is on. It runs in your browser, nothing is sent.
Runs entirely in your browser — nothing you paste is sent anywhere. Tokens and passwords that appear in paths or URLs are masked in the output.
Optional: package.json, file extension, Node.js version (sharper advice)
Fix: choose one module system for the loader#
The asker wanted synchronous loading at startup. That is possible, but only with require(): import() returns a promise in every Node release, so the async loader below is the alternative, not a variant.
1. Synchronous: CommonJS, require() in a for loop#
Keep "module": "commonjs", remove "type": "module" from package.json, and rename the route files to .ts so the compiler emits CommonJS for them too. A route file keeps its import syntax:
// src/routes/module-streams.ts
import express from 'express';
export default (services?: unknown) => {
const router = express.Router();
router.route('/').get((req, res) => res.send({ route: 'module-streams' }));
return router;
};The loader reads its own folder, skips itself, and requires each file in a stable order:
// src/routes/index.ts
import fs from 'fs';
import path from 'path';
import { Express, Router } from 'express';
// .ts under ts-node-dev, .js after tsc: load files with the same extension as this one
const ext = path.extname(__filename);
export const routes = (app: Express) => {
const files = fs.readdirSync(__dirname)
.filter(name => path.extname(name) === ext && !name.endsWith('.d.ts'))
.filter(name => path.basename(name, ext) !== 'index')
.sort();
for (const file of files) {
const routeName = path.basename(file, ext);
const mod = require(path.join(__dirname, file));
const createRouter: (services?: unknown) => Router = mod.default;
app.use(`/${routeName}`, createRouter());
console.log(`mounted /${routeName}`);
}
};Three choices in it are about folders, not modules. __dirname replaces process.cwd() + '/src/routes': the working directory is only /app because the Dockerfile says WORKDIR /app, and after a build the files live in dist/routes, not src/routes. Matching the loader's own extension means the same code loads .ts files under ts-node-dev and .js files from dist/, and never picks up a .map or .d.ts that a build writes next to them. sort() keeps the mount order independent of the order the filesystem lists entries in.
The server calls the loader before listen():
// src/server.ts
import express from 'express';
import { routes } from './routes';
const app = express();
routes(app);
app.listen(3000, () => console.log('listening on 3000'));2. Asynchronous: native ES modules, await import() in a for...of loop#
If the project should stay ESM, keep "type": "module" and move the compiler and the dev runner to tools that emit real import(). The run below used TypeScript 7.0.2 and tsx 4.23.15 on Node 22.23.3. The tsconfig.json:
{
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
}
}The loader awaits each import inside a for...of loop:
// src/routes/index.ts
import { readdir } from 'node:fs/promises';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { Express, Router } from 'express';
// .ts under tsx, .js after tsc: load files with the same extension as this one
const ext = path.extname(import.meta.filename);
export async function routes(app: Express) {
const files = (await readdir(import.meta.dirname))
.filter(name => path.extname(name) === ext && !name.endsWith('.d.ts'))
.filter(name => path.basename(name, ext) !== 'index')
.sort();
for (const file of files) {
const routeName = path.basename(file, ext);
const url = pathToFileURL(path.join(import.meta.dirname, file)).href;
const mod = await import(url);
const createRouter: (services?: unknown) => Router = mod.default;
app.use(`/${routeName}`, createRouter());
console.log(`mounted /${routeName}`);
}
}import.meta.dirname and import.meta.filename were added in Node v20.11.0 and v21.2.0 according to the import.meta documentation; on older releases, derive the folder from import.meta.url with fileURLToPath. pathToFileURL turns the absolute path into the file: URL form that the Node.js ESM terminology uses for absolute specifiers. The server awaits the loader with top-level await, which ES modules allow, and imports ./routes/index.js by its full name, because ES module resolution does not look for an index file in a folder (see Directory import is not supported):
// src/server.ts
import express from 'express';
import { routes } from './routes/index.js';
const app = express();
await routes(app);
app.listen(3000, () => console.log('listening on 3000'));Promise.all(files.map(f => import(...))) loads the files in parallel instead. Its result array follows the order of the input array, so awaiting it and then calling app.use() over the results keeps the sorted order. Only an app.use() placed inside each import's .then() would mount routes in whatever order the imports finish.
3. Keep the ES module route files and require() them#
The accepted answer on the question reaches synchronous loading with a library, import-sync, together with ts-node-dev 2.0.0 and without "type": "module". On current Node releases the runtime does it without a dependency. The Node.js modules documentation lists require() of ES modules as no longer behind a flag from v22.12.0 and v20.19.0, and adds that a module whose graph contains top-level await throws ERR_REQUIRE_ASYNC_MODULE instead and must be loaded with import(). Under ts-node-dev, the original .js route files, "type": "module" and ts-node-dev 1.1.8 can therefore stay, with the loop from fix 1 pointed at .js files. Reproduced under npm start, the same project loads on Node 24 and fails on Node 20.18.3:
### node v24.21.0: ESM route files kept (.js, "type": "module"), require() in a for loop, ts-node-dev 1.1.8
mounted /module-streams
listening on 3000
{"route":"module-streams"}
### node v20.18.3: ESM route files kept (.js, "type": "module"), require() in a for loop, ts-node-dev 1.1.8
Error [ERR_REQUIRE_ESM]: require() of ES Module /app/src/routes/module-streams.js from /app/src/routes/index.ts not supported.This option covers the ts-node-dev development server only. Compiled with tsc and "module": "commonjs" while "type": "module" stays in package.json, the dist/ files fail at start-up with exports is not defined in ES module scope (shown in the last section below, and explained in exports is not defined in ES module scope). For a build you run with node dist/server.js, use fix 1 or fix 2. Even in development, this option ties the project to the Node version in the image, so pin the tag (node:alpine moves with every release) and keep top-level await out of route files.
Verify the fix#
Request one route per file once the server prints listening. With fix 1 under ts-node-dev 1.1.8 on Node 18, the mounts are logged before listen, which is the order the asker wanted:
### node v18.20.8: fix 1, no "type": "module", require() in a for loop, npm start (ts-node-dev 1.1.8)
mounted /module-streams
mounted /orders
listening on 3000
{"route":"module-streams"}
{"route":"orders"}Then check the built output, because the extension filter and __dirname only prove themselves outside src/. After npm run build (tsc --outDir dist), node dist/server.js logged both mounts from dist/routes and served /orders. Fix 2 gave the same result under tsx src/server.ts and under node dist/server.js on Node 22, and the compiled loader kept a real const mod = await import(url);.
One email a month — the latest explained errors
What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.
When the build or the container fails next#
exports is not defined in ES module scope after the build. ts-node-dev compiled fix 1 happily even with "type": "module" left in package.json. The built files did not run:
### negative control: same dist/, "type": "module" put back in package.json
ReferenceError: exports is not defined in ES module scope
This file is being treated as an ES module because it has a '.js' file extension and '/app/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension.That is why fix 1 removes the field, and the exports is not defined in ES module scope page covers the other ways out.
ts-node-dev: not found in the container. The question's Dockerfile runs npm install --only=prod, and ts-node-dev is a devDependency. Built on its own, with nothing mounted over /app, that image stops before your loader runs. Replaying those steps in node:18-alpine with npm 10.8.2:
npm warn config only Use `--omit=dev` to omit dev dependencies from the install.
> ts-node-dev src/server.ts
sh: ts-node-dev: not foundFor a development image, install everything; for production, build with tsc in one stage and run node dist/server.js in the next, as in Cannot find module '/dist/main' in a Docker build, which also covers a dist/ folder that ends up without the expected file. If you bind-mount the project into the container for live reload, the mount hides the image's node_modules; Cannot find module in Docker Compose shows the volume that keeps it.
A directory loader is mostly a module-system decision made once: CommonJS gives you the synchronous require() loop the question asked for, ES modules give you await import() and top-level await to run it before listen(). Whichever you choose, build the folder path from the loader's own location and its own extension, so the same code keeps working when src/routes becomes dist/routes inside the image.
Further reading: SyntaxError: Unexpected token import means an old Node.js.
Frequently Asked Questions
Still stuck? Paste your error.
Paste the error message or stack trace — we check it against every fix and tool on the site.
Continue Reading
Fix: Directory import is not supported resolving ES modules
Why Node throws ERR_UNSUPPORTED_DIR_IMPORT under "type": "module", three fixes reproduced on Node 24, and why the old specifier flag no longer helps.
Fix ERR_MODULE_NOT_FOUND: relative import missing .js
Why an Express app with "type": "module" cannot find ./config/app, why the flags from older answers now do nothing, and how TypeScript catches it at compile time.
Fix return old(m, filename) ERR_REQUIRE_ESM in ts-node
Why the ts-node frame appears, why the dynamic import() the error suggests still fails under module commonjs, and five fixes tested on Node 18 and 24.


