The Cleanest Node.js + TypeScript Setup I Use for Every Backend Project
When I first started setting up Node.js projects with TypeScript, the setup always felt more complicated than it needed to be.
Some tutorials used ts-node, others used Babel, some required weird config combinations, and half of them broke the moment ES Modules entered the picture.
After rebuilding the same backend setup over and over again, I eventually settled on a stack that’s simple, fast, and doesn’t fight me during development.
Right now, my default setup is just:
- ✦TypeScript
- ✦
tsx - ✦
nodemon - ✦ES Modules
That’s it.
No heavy boilerplate. No unnecessary abstractions. Just a clean developer experience with fast reloads and a setup that feels modern without becoming overengineered.
In this blog, I’ll show you exactly how I initialize this setup for every backend project.
Prerequisites
All you need before starting is Node.js installed on your system.
Quick check:
node -v
npm -vInitializing the Project
First, let’s create a new Node.js project.
mkdir my-new-project
cd my-new-project
npm init -yThe npm init -y command quickly creates a package.json file with the default configuration.
If you run npm init without the -y flag, npm will ask you a bunch of setup questions like the project name, version, entry file, author, and more. The -y simply skips all of that and accepts the defaults automatically.
The important part here is the package.json file.
This file basically becomes the heart of your backend project. It stores:
- ✦project metadata
- ✦installed dependencies
- ✦scripts like
npm run dev - ✦module configuration
- ✦package versions
Pretty much every tool in the Node.js ecosystem relies on this file in some way.
At this point, your project structure should look something like this:
my-new-project/
└── package.jsonInstalling Dependencies
Now that the project is initialized, it’s time to install the tools that will power the development workflow.
I usually separate this into two parts:
- ✦development dependencies
- ✦actual runtime dependencies
First, install the development tools:
npm install typescript tsx ts-node nodemon @types/node --save-devHere’s what each package is doing:
- ✦TypeScript → Adds TypeScript support to the project.
- ✦
tsx→ Lets us run TypeScript files directly without manually compiling them first. This is the main reason the setup feels clean and fast. - ✦
ts-node→ Another TypeScript execution tool. I still keep it installed because some tools and workflows rely on it internally. - ✦
nodemon→ Automatically restarts the server whenever files change during development. - ✦
@types/node→ Provides TypeScript type definitions for Node.js APIs like fs, path, process, etc.
The --save-dev flag means these packages are only needed during development and won’t be included as production dependencies.
Next, install the actual backend dependencies:
npm install express dotenv corsAnd their TypeScript type definitions:
npm install @types/express @types/cors --save-devQuick breakdown:
- ✦
express→ Minimal backend framework for creating APIs and servers. - ✦
dotenv→ Loads environment variables from a .env file. - ✦
cors→ Allows your backend to handle cross-origin requests properly.
Since TypeScript needs type information for JavaScript libraries, we also install:
- ✦
@types/express - ✦
@types/cors
These provide IntelliSense, autocomplete, and proper type safety while coding.
After installing everything, your project finally starts feeling like a real backend setup instead of an empty folder.
Configuring package.json
This is the part where the project starts becoming modern instead of just “working”.
A lot of older Node.js + TypeScript setups still use CommonJS, awkward execution commands, or unnecessarily complicated tooling. I prefer keeping things closer to how modern JavaScript actually works today.
Open your package.json and update it like this:
{
"name": "my-new-project",
"version": "1.0.0",
"description": "",
"main": "dist/index.js",
"type": "module",
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts",
"build": "tsc"
},
"keywords": [],
"author": "",
"license": "ISC"
}Now let’s break down the important parts.
Why "type": "module"?
By default, Node.js uses the older CommonJS system:
const express = require("express");But modern JavaScript uses ES Modules:
import express from "express";Adding:
"type": "module"tells Node.js to use the modern ES Module system everywhere in the project.
This keeps the syntax aligned with frontend frameworks, modern tooling, and the overall JavaScript ecosystem today.
Honestly, once you get used to import/export, going back to require() feels ancient.
Why set "main": "dist/index.js"?
When TypeScript compiles your code, the generated JavaScript files will go into the dist folder.
So this:
"main": "dist/index.js"tells Node.js where the actual production entry file lives after building the project.
Think of it as:
- ✦
src/→ development code - ✦
dist/→ compiled production code
Why use tsx instead of plain ts-node?
This is probably my favorite part of the setup.
Older TypeScript setups often use commands like:
ts-node src/index.tsIt works… but tsx just feels smoother.
Using tsx gives:
- ✦faster startup
- ✦better ES Module support
- ✦cleaner developer experience
- ✦less configuration pain
That’s why the dev script looks like this:
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts"Here’s what’s happening:
- ✦
nodemonwatches for file changes - ✦
tsxexecutes TypeScript directly - ✦the server automatically restarts on save
No manual compiling. No annoying workflow friction.
Why separate dev, build, and start scripts?
Each script has a different responsibility:
- ✦
“dev”: Runs the development server with hot reloads. - ✦
“build”: Compiles TypeScript into JavaScript. - ✦
“start”: Runs the compiled production build.
This separation keeps development and production workflows clean and predictable.
And honestly, once you structure projects this way, it becomes hard to go back to messy one-command setups.
Configuring TypeScript
Now it’s time to configure TypeScript itself.
You can generate a default configuration using:
npx tsc --initBut the default tsconfig.json usually comes with a lot of unnecessary stuff for backend projects.
Instead, I prefer using a cleaner configuration tailored specifically for modern Node.js + ES Module workflows.
Create a tsconfig.json file in the root of your project and paste this:
{
"compilerOptions": {
"target": "es2022",
"module": "esnext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": false,
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}Now let’s break down the important parts because this is where a lot of TypeScript confusion usually starts.
Why "target": "es2022"?
This tells TypeScript which JavaScript version to compile your code into.
"target": "es2022"Since modern versions of Node.js already support most modern JavaScript features, there’s no reason to compile down to older versions like ES5 anymore.
Using ES2022 gives you access to newer JavaScript features while keeping the output cleaner and faster.
Why "module": "esnext"?
This works together with:
"type": "module"from package.json.
It tells TypeScript to preserve modern ES Module syntax instead of converting everything into CommonJS.
Without this, you can end up mixing module systems and suddenly spend 30 minutes debugging import errors that make no sense.
Why "moduleResolution": "bundler"?
This is one of the most important settings in the entire setup.
"moduleResolution": "bundler"This setting controls how TypeScript resolves import paths internally.
For this setup, I prefer using "bundler" because it works smoothly with modern tooling like tsx and ES Module workflows.
Compared to older Node.js resolution behavior, it generally feels more predictable when working with modern import/export syntax.
It also helps avoid some common issues related to:
- ✦extension resolution
- ✦import path handling
- ✦ES Module compatibility
That said, some developers prefer:
"moduleResolution": "nodenext"especially for projects that closely follow native Node.js ES Module behavior.
In my experience, "bundler" keeps the development workflow simpler for this particular setup.
Why "rootDir" and "outDir"?
"rootDir": "./src",
"outDir": "./dist"This keeps the project organized.
- ✦
src→ your TypeScript source code - ✦
dist→ compiled JavaScript output
When you run:
npm run buildTypeScript compiles everything from src into dist.
Why "strict": true?
"strict": trueThis enables strict type checking.
And honestly… keep this enabled.
Yes, TypeScript becomes slightly more annoying sometimes, but it catches bugs early and prevents a lot of runtime issues later.
The whole point of using TypeScript is type safety — disabling strict mode defeats a huge part of the benefit.
Why "esModuleInterop": true?
Some Node.js packages still use older CommonJS exports internally.
This option improves compatibility so imports work more naturally.
Without it, you sometimes end up writing awkward import syntax just to satisfy TypeScript.
Why "skipLibCheck": true?
"skipLibCheck": trueThis skips type checking inside external dependency declaration files.
In simple terms: TypeScript stops wasting time checking library internals you don’t control anyway.
This speeds up compilation and avoids random type issues coming from third-party packages.
Why `"include": ["src//"]`?*
This tells TypeScript exactly which files should be included in the compilation process.
In this setup, we only want files inside the src directory to be compiled.
At this point, the foundation of the backend setup is basically complete. Your project structure should look something like this:
my-new-project/
├── node_modules/
├──src/
├── package.json
├── package-lock.json
└── tsconfig.jsonDon’t worry if the src folder doesn’t exist yet — we’ll create it in the next step.
Now we can finally start writing actual application code instead of configuring tooling for the next three hours.
Creating Project Structure
Now let’s create the actual source directory for the application.
Run:
mkdir src
touch src/index.tsThis gives us:
- ✦a
srcfolder for all application code - ✦an
index.tsentry file where the backend starts
I prefer keeping all source code inside src right from the beginning because it keeps the project structure clean as the backend grows.
Your structure should now look like this:
my-new-project/
├── node_modules/
├── src/
│ └── index.ts
├── package.json
├── package-lock.json
└── tsconfig.jsonNow add a simple code inside src/index.ts:
const message: string = "Hello from TypeScript and Node.js!";
console.log(message);This is just a quick sanity check to confirm:
- ✦TypeScript is compiling correctly
- ✦
tsxis working - ✦the project setup is functioning properly
If this runs successfully, the entire setup pipeline is basically ready.
Adding .gitignore and .env
Before moving forward, let’s clean up the project a little.
Create a .gitignore file and an empty .env file:
echo "node_modules/\ndist/\n.env" > .gitignore
touch .envHere’s why these files matter.
Why .gitignore?
Some files should never be committed to Git.
For example:
- ✦
node_modules/→ can be reinstalled anytime using npm install - ✦
dist/→ generated automatically during build - ✦
.env→ usually contains secrets like API keys or database URLs
Without a proper .gitignore, repositories become messy very quickly.
Why create .env early?
Even if you don’t need environment variables yet, most backend projects eventually will.
Things like:
- ✦database URLs
- ✦JWT secrets
- ✦API keys
- ✦port numbers
usually live inside .env.
Creating it early helps establish a cleaner project structure from the start.
At this point, your setup is fully ready to run for the first time.
Running the Application
Everything is set up now, so let’s finally run the project.
Development Mode
Start the development server with:
npm run devThis uses:
- ✦
nodemonto watch for file changes - ✦
tsxto execute TypeScript directly
So whenever you save a file, the server automatically restarts.
This is the workflow you’ll probably use most of the time during development.
If everything is working correctly, you should see:
Hello from TypeScript and Node.js!inside the terminal.
Build the Project
To compile the TypeScript code into JavaScript, run:
npm run buildThis generates a dist folder containing the compiled JavaScript files.
Your structure will now look something like this:
my-new-project/
├── dist/
├── node_modules/
├── src/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── tsconfig.jsonThe dist folder is what you’d typically deploy in production environments.
Start the Production Build
To run the compiled JavaScript version:
npm run startThis executes:
node dist/index.jswhich runs the production-ready build instead of the raw TypeScript source files.
And that’s basically the entire setup.
A clean modern Node.js + TypeScript backend workflow with:
- ✦ES Modules
- ✦fast TypeScript execution using tsx
- ✦automatic reloads with nodemon
- ✦clean project structure
- ✦minimal configuration overhead
This is the exact setup I keep reusing for almost every backend project because it stays simple without sacrificing developer experience.
Common ES Module Import Error (Important)
If you're using ES Modules ("type": "module" in package.json), you may run into this issue after building the project.
For example, this import may work perfectly during development:
import config from "./config/config";The app may also compile successfully with npm run build.
However, running the production build using:
npm startcan throw an error like:
Error [ERR_MODULE_NOT_FOUND]: Cannot find module ...This happens because Node.js ES Modules require explicit file extensions at runtime.
The fix: Use the .js extension directly in your TypeScript imports:
import config from "./config/config.js";Even though you're writing TypeScript files (.ts), the compiled output becomes .js, so Node expects .js imports in production.
After updating the imports, both development and production builds will work correctly.
Initiate Connection
Open to building scalable systems and real-time applications. If you have something interesting, let’s connect.