If you have been working with Node.js for a while, you know the ecosystem shifts rapidly. What was “best practice” in 2023 might be considered legacy technical debt today.
As we move through 2025, the Node.js landscape has matured significantly regarding TypeScript integration. We are moving away from complex Webpack shims for backend code and embracing native ESM (ECMAScript Modules), high-performance runtime wrappers like tsx, and strict compilation targets.
This guide isn’t about teaching you TypeScript syntax. It is about architectural hygiene. We will build a bulletproof, production-ready Node.js TypeScript boilerplate that prioritizes developer experience (DX), build speed, and runtime performance.
Prerequisites and Environment #
Before we write a single line of configuration, ensure your environment matches modern standards. Using outdated Node versions is the #1 cause of moduleResolution headaches.
- Node.js: v20 (LTS) or v22 (Current). We need native ESM support.
- Package Manager:
npm(v10+) orpnpm(recommended for monorepos). We will usenpmin examples for universality. - IDE: VS Code with the official ESLint and Prettier extensions installed.
Step 1: Initialization and Core Dependencies #
Let’s start fresh. Create a directory and initialize your project.
mkdir node-ts-pro-2025
cd node-ts-pro-2025
npm init -yNow, let’s install the critical dependencies.
The Shift: In the past, we relied heavily on ts-node. However, for local development in 2025, tsx (powered by esbuild) is the superior choice due to its incredible speed and native ESM support.
# Core TypeScript dependencies
npm install -D typescript @types/node tsx
# Tooling for code quality (more on this later)
npm install -D eslint @eslint/js typescript-eslint prettier eslint-config-prettierStep 2: The Modern tsconfig.json Strategy
#
The tsconfig.json file is where most developers get stuck, specifically regarding modules. To align with modern Node.js, we must use NodeNext.
Create a tsconfig.json in your root:
{
"compilerOptions": {
/* Base Options */
"target": "ES2022", /* Generate modern JS */
"lib": ["ES2023"], /* Include modern library definitions */
"module": "NodeNext", /* Crucial for ESM support */
"moduleResolution": "NodeNext", /* Aligns module resolution with Node */
"rootDir": "./src", /* Specify the root directory of input files */
"outDir": "./dist", /* Specify an output folder for all emitted files */
/* Strictness */
"strict": true, /* Enable all strict type-checking options */
"noImplicitAny": true,
"strictNullChecks": true,
/* Interop Constraints */
"esModuleInterop": true, /* Emit additional JS to ease support for CommonJS modules */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports */
"skipLibCheck": true, /* Skip type checking all .d.ts files (faster builds) */
/* Source Maps for Debugging */
"sourceMap": true,
"declaration": true /* Generate .d.ts files (useful for libraries) */
},
"include": ["src/**/*"],
"exclude": ["node_modules", "**/*.spec.ts"]
}Why NodeNext?
#
Setting module and moduleResolution to NodeNext forces TypeScript to respect Node’s native module rules. It allows you to use Top-Level Await and ensures that if you import a file, the extension logic matches exactly what Node expects in production.
Step 3: Project Structure and Source Code #
Professional Node applications require separation of concerns. Do not dump files in the root.
- Create a
srcfolder. - Add
src/index.ts.
// src/index.ts
import { createServer } from 'node:http';
const PORT = process.env.PORT || 3000;
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
message: 'Hello from modern Node.js + TypeScript!',
timestamp: new Date().toISOString()
}));
});
server.listen(PORT, () => {
console.log(`馃殌 Server running on http://localhost:${PORT}`);
console.log(`Running in ${process.env.NODE_ENV || 'development'} mode`);
});Note the use of node:http. Using the node: protocol is a best practice to clearly distinguish built-in modules from npm packages.
Step 4: Package Configuration and Scripts #
We need to tell Node.js that this package uses ESM modules. Open package.json and add "type": "module". This is non-negotiable for the setup we just created.
Here is how your package.json scripts should look:
{
"name": "node-ts-pro-2025",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"lint": "eslint src/**/*.ts",
"format": "prettier --write ."
},
"devDependencies": {
"...": "..."
}
}The Workflow Visualization #
Understanding how code flows from your IDE to execution is vital for debugging.
Step 5: Linting and Formatting (The 2025 Standard) #
Gone are the days of complex .eslintrc files. We are using the Flat Config system.
Create eslint.config.js (not .json) in the root:
// eslint.config.js
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
rules: {
'no-console': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/explicit-function-return-type': 'off'
}
}
);Create a .prettierrc:
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 2
}Tooling Comparison: Why change? #
Many developers ask why we shouldn’t just stick with ts-node or babel. Here is the breakdown for the current ecosystem.
| Feature | tsx (Recommended) |
ts-node |
tsc + node |
|---|---|---|---|
| Engine | esbuild (Go) | TypeScript Compiler | TypeScript Compiler |
| Speed | 鈿★笍 Extremely Fast | 馃悽 Slow (on large projects) | 馃悽 Slow build, Fast run |
| Type Checking | No (Transpile only) | Yes (optional) | Yes |
| ESM Support | Excellent (Native) | Complex config needed | Excellent (NodeNext) |
| Use Case | Local Development | Legacy / Scripts | Production Build |
Best Practices & Common Pitfalls #
1. Do Not Run TypeScript in Production #
While tools like ts-node can run in production, they shouldn’t. They consume excessive memory and CPU to compile code on the fly. always run npm run build (using tsc) and deploy the dist/ folder.
2. Handling Path Aliases #
You might want to use imports like import user from '@/models/user'.
While you can configure this in tsconfig.json under paths, Node.js does not understand these paths at runtime.
- Solution: For libraries, avoid path aliases. For applications, you must use a tool like
tsc-aliasduring the build process to resolve these paths to relative ones (e.g.,../../models/user) in the final JS output.
3. Separation of Dev vs. Prod Dependencies #
Keep your image size small. typescript, tsx, eslint, and @types/* packages should strictly be in devDependencies. Your production Docker container or server only needs the dist folder and dependencies.
Conclusion #
Setting up Node.js with TypeScript in 2025 doesn’t have to be a configuration nightmare. By embracing ESM, leveraging NodeNext, and utilizing faster tooling like tsx, you create a development environment that is both strict on quality and incredibly fast to work with.
Summary of the Stack:
- Runtime: Node.js v20+
- Language: TypeScript with
NodeNextmodules. - Dev Tool:
tsxfor instant feedback. - Prod Tool:
tscfor type-checked, clean builds.
Start your next project with this foundation, and you will spend less time fighting configuration and more time shipping features.