Setting up Node with TypeScript Troubles
Bootstrapping a new Node.js + TypeScript project should be simple by now, right? Wrong.
Every time I start fresh, I’m reminded how boring and error-prone it is to set up manually. Add to that the fast-changing JavaScript/Node ecosystem, and suddenly half the guides out there are outdated or untested.
Following a Guide That Doesn’t Work Out of the Box
I tried to follow this LogRocket guide. Looked fine on paper, recently updated… but reality check: it doesn’t work.
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/workspaces/backend-node/src/app.js' imported from /workspaces/backend-node/src/server.ts
at finalizeResolution (node:internal/modules/esm/resolve:274:11)
...
url: 'file:///workspaces/backend-node/src/app.js'Why is it looking for app.js when the file is actually app.ts?
The ESM + TypeScript Import Headache
Here’s the deal: with "type": "module" and "moduleResolution": "nodenext", TypeScript forces you to include .js file extensions in your imports, even when you’re importing .ts files during development. Because at runtime, everything compiles to .js.
That means in server.ts you can’t just write:
import { createApp } from "./app.ts";You actually need:
import { createApp } from "./app.js";Yes, even though app.js doesn’t exist yet. Also I tried relative imports too:
import { createApp } from "./app"But it doesn’t work, here is the error from TypeScript’s language server:
Relative import paths need explicit file extensions in ECMAScript imports when '--moduleResolution' is 'node16' or 'nodenext'. Did you mean './app.js'?ts(2835)To make it work, I patched tsconfig.json:
"ts-node": {
"esm": true,
"experimentalSpecifierResolution": "node"
}
Now it runs… With .js file imports, but with this friendly warning:
ExperimentalWarning: --experimental-loader may be removed in the futureA Monster Dev Script
The docs suggest using register() instead. So my dev script in package.json grew into this beast:
{
...
"dev": "nodemon --watch 'src/**/*.ts' --exec \"node --import 'data:text/javascript,import { register } from \\\"node:module\\\"; import { pathToFileURL } from \\\"node:url\\\"; register(\\\"ts-node/esm\\\", pathToFileURL(\\\"./\\\"));'\" src/server.ts",
...
}It worked without warnings… until another deprecation warning popped up (fs.Stats constructor in newer Node versions).
At this point, I was done.
Enter tsx
Instead of fighting ts-node, I switched to tsx. It’s simpler, faster, and just works.
My new dev script:
{
...
"dev": "nodemon --watch 'src/**/*.ts' --exec 'tsx' src/server.ts",
...
}Clean. Minimal. No experimental flags.
Ditch dotenv, Too
The guide also recommended dotenv for environment variables. But since Node 20.6, you can use --env-file out of the box. Current stable (Node 24.5) already has this baked in. No need for extra dependencies.
Final package.json
Here’s what I ended up with:
{
"name": "backend-node",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "nodemon --watch 'src/**/*.ts' --exec 'tsx --env-file=.env' src/server.ts",
"lint": "eslint 'src/**/*.ts'"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"dependencies": {
"express": "5.1.0"
},
"devDependencies": {
"@types/express": "5.0.3",
"@types/node": "24.3.0",
"eslint": "9.33.0",
"nodemon": "3.1.10",
"prettier": "3.6.2",
"tsx": "4.20.4",
"typescript": "5.9.2"
}
}Final Thoughts
Bootstrapping a Node + TypeScript project shouldn’t feel like fighting bosses on Nightmare difficulty. But here we are.
At least with tsx and modern Node features, the setup is cleaner – until the next ecosystem shift breaks everything again.
Happy coding. Until it breaks again. 😉