Last updated: May 28, 2026

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:

shell
node -v
npm -v

Initializing the Project

First, let’s create a new Node.js project.

shell
mkdir my-new-project
cd my-new-project
npm init -y

The 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:

text
my-new-project/
└── package.json

Installing 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:

shell
npm install typescript tsx ts-node nodemon @types/node --save-dev

Here’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:

shell
npm install express dotenv cors

And their TypeScript type definitions:

shell
npm install @types/express @types/cors --save-dev

Quick 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:

json
{
  "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:

JS
const express = require("express");

But modern JavaScript uses ES Modules:

JS
import express from "express";

Adding:

json
"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:

json
"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:

shell
ts-node src/index.ts

It 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:

json
"dev": "nodemon --watch src --ext ts --exec tsx src/index.ts"

Here’s what’s happening:

  • nodemon watches for file changes
  • tsx executes 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:

shell
npx tsc --init

But 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:

json
{
  "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.

json
"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:

json
"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.

json
"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:

json
"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"?

json
"rootDir": "./src",
"outDir": "./dist"

This keeps the project organized.

  • src → your TypeScript source code
  • dist → compiled JavaScript output

When you run:

shell
npm run build

TypeScript compiles everything from src into dist.

Why "strict": true?

json
"strict": true

This 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?

json
"skipLibCheck": true

This 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:

text
my-new-project/
├── node_modules/
├──src/
├── package.json
├── package-lock.json
└── tsconfig.json

Don’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:

shell
mkdir src
touch src/index.ts

This gives us:

  • a src folder for all application code
  • an index.ts entry 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:

text
my-new-project/
├── node_modules/
├── src/
│ └── index.ts
├── package.json
├── package-lock.json
└── tsconfig.json

Now add a simple code inside src/index.ts:

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
  • tsx is 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:

shell
echo "node_modules/\ndist/\n.env" > .gitignore
touch .env

Here’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:

shell
npm run dev

This uses:

  • nodemon to watch for file changes
  • tsx to 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:

shell_output
Hello from TypeScript and Node.js!

inside the terminal.

Build the Project

To compile the TypeScript code into JavaScript, run:

shell
npm run build

This generates a dist folder containing the compiled JavaScript files.

Your structure will now look something like this:

text
my-new-project/
├── dist/
├── node_modules/
├── src/
├── .env
├── .gitignore
├── package.json
├── package-lock.json
└── tsconfig.json

The dist folder is what you’d typically deploy in production environments.

Start the Production Build

To run the compiled JavaScript version:

shell
npm run start

This executes:

shell
node dist/index.js

which 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:

TS
import config from "./config/config";

The app may also compile successfully with npm run build.

However, running the production build using:

shell
npm start

can throw an error like:

shell_output
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:

TS
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.

Module: Connection.

Initiate Connection

Open to building scalable systems and real-time applications. If you have something interesting, lets connect.

System: Available
Connection Interface>_
Your information is secure and will never be shared.