TypeScript: Setup Project for Node.js Backend

You know TypeScript. You've built frontends with it. Now you want to build a backend — a real API, not a toy project. But the first hurdle isn't writing business logic. It's setting up the project properly.
A bad setup haunts you for months: slow builds, mysterious import errors, no auto-formatting, and a tsconfig.json copied from Stack Overflow that nobody understands. A good setup is invisible — it just works, catches bugs early, and lets you focus on what matters.
This guide walks you through a production-ready TypeScript backend setup from scratch. Every decision is explained so you actually understand your own configuration.
Time commitment: 1-2 hours
Prerequisites: TypeScript Phase 1: Fundamentals and basic Node.js experience
What You'll Learn
✅ Initialize a TypeScript project for Node.js from scratch
✅ Configure tsconfig.json with every option explained
✅ Set up ESLint 9 + Prettier for consistent code
✅ Development workflow with tsx (fast TypeScript execution)
✅ Build process with tsc, esbuild, and swc
✅ Project structure best practices for scalable backends
✅ Environment variables with type-safe validation
✅ Path aliases that work in both dev and production
1. Initialize the Project
Start fresh:
mkdir my-api
cd my-api
npm init -yInstall TypeScript and Node.js types:
npm install -D typescript @types/nodeWhy
@types/node? Node.js is written in C++, not TypeScript. The@types/nodepackage provides TypeScript type definitions for all Node.js built-in modules (fs,path,http,process, etc.). Without it, TypeScript has no idea whatprocess.envorBufferis.
Generate a tsconfig.json:
npx tsc --initThis creates a file with every option commented out. Let's replace it with something we actually understand.
2. TypeScript Configuration (tsconfig.json)
Here's a production-ready configuration for Node.js backends. Every option is explained:
{
"compilerOptions": {
// === Output ===
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "./dist",
"rootDir": "./src",
// === Strictness ===
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
// === Module Features ===
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
// === Quality of Life ===
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"skipLibCheck": true,
// === Path Aliases ===
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
}Key Options Explained
target: "ES2022" — What JavaScript version to emit. ES2022 gives you top-level await, Array.at(), Object.hasOwn(), and class fields. Node.js 18+ supports all ES2022 features natively.
module: "NodeNext" + moduleResolution: "NodeNext" — This pair tells TypeScript to follow Node.js's native module resolution. It supports both ESM (import) and CJS (require) based on your package.json's "type" field. This is the recommended setting for Node.js projects in 2025+.
strict: true — Enables all strict type-checking options at once. This is non-negotiable for professional code. It includes strictNullChecks, strictFunctionTypes, strictBindCallApply, and more.
noUncheckedIndexedAccess: true — A hidden gem. When you access an array element or object property by index, the result includes undefined:
const items = ["a", "b", "c"];
const first = items[0]; // string | undefined (not just string)
// Forces you to handle the edge case
if (first !== undefined) {
console.log(first.toUpperCase()); // safe
}esModuleInterop: true — Allows import express from 'express' instead of import * as express from 'express'. Most npm packages are CommonJS, and this flag makes them work cleanly with ES import syntax.
resolveJsonModule: true — Lets you import config from './config.json' with full type inference. TypeScript reads the JSON structure and gives you typed access.
isolatedModules: true — Ensures each file can be transpiled independently. Required if you plan to use esbuild, swc, or any tool that compiles files individually (which is most tools).
skipLibCheck: true — Skips type-checking .d.ts files from node_modules. Dramatically speeds up compilation. You almost always want this — if a library's types are broken, that's their problem to fix.
ESM vs CJS: Which to Choose?
Add this to your package.json for ESM:
{
"type": "module"
}ESM (recommended):
- Top-level
await import/exportsyntax- Tree-shakeable
- The standard going forward
CJS (legacy):
require()/module.exports- Wider compatibility with older packages
- No top-level
await
Practical advice: Use ESM for new projects. If you hit compatibility issues with an old package, you can dynamically
import()it or use a compatibility wrapper.
3. ESLint 9 + Prettier Setup
Install Dependencies
# ESLint 9 with TypeScript support
npm install -D eslint @eslint/js typescript-eslint
# Prettier
npm install -D prettier eslint-config-prettierESLint Configuration (eslint.config.mjs)
ESLint 9 uses the new flat config format:
// eslint.config.mjs
import eslint from "@eslint/js";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintConfigPrettier,
{
languageOptions: {
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// Practical rules for backend development
"@typescript-eslint/no-unused-vars": [
"error",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/prefer-nullish-coalescing": "warn",
"@typescript-eslint/prefer-optional-chain": "warn",
},
},
{
ignores: ["dist/", "node_modules/", "*.js"],
}
);Why These Rules Matter
no-floating-promises: "error" — This is the single most important rule for backend development. A floating promise is one you forgot to await:
// BUG: Error is silently swallowed
app.get("/users", (req, res) => {
fetchUsers(); // Forgot await! No error handling!
res.json({ ok: true }); // Sends response before data is ready
});
// FIXED: ESLint catches this
app.get("/users", async (req, res) => {
const users = await fetchUsers(); // Now we wait
res.json(users);
});no-misused-promises: "error" — Catches async functions passed where sync callbacks are expected. This is a common source of unhandled rejections.
Prettier Configuration (.prettierrc)
{
"semi": true,
"trailingComma": "all",
"singleQuote": false,
"printWidth": 100,
"tabWidth": 2
}Add Scripts to package.json
{
"scripts": {
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.ts'"
}
}4. Development Workflow
You need a way to run TypeScript during development without manually compiling every time. Here are three options, from simplest to most configurable.
Option 1: tsx (Recommended)
tsx is a blazing-fast TypeScript executor powered by esbuild. It just works — no config, no fuss:
npm install -D tsx{
"scripts": {
"dev": "tsx watch src/index.ts"
}
}Why tsx?
- Zero config — no
tsconfig.jsonneeded for execution (it still respects yours) - Built-in watch mode with
tsx watch - Handles ESM and CJS seamlessly
- ~10x faster than
ts-node - Supports path aliases out of the box
Option 2: ts-node (Legacy)
The original TypeScript executor. Still widely used but slower:
npm install -D ts-node{
"scripts": {
"dev": "ts-node --esm --watch src/index.ts"
}
}When to use
ts-node: If you need full TypeScript type-checking during execution (tsx skips type-checking for speed). For most development workflows, type-checking in your IDE +tsc --noEmitin CI is sufficient.
Option 3: nodemon + tsx
For more control over what triggers a restart:
npm install -D nodemon tsxCreate nodemon.json:
{
"watch": ["src"],
"ext": "ts,json",
"ignore": ["src/**/*.test.ts"],
"exec": "tsx src/index.ts"
}{
"scripts": {
"dev": "nodemon"
}
}Quick Start File
Create src/index.ts to verify everything works:
// src/index.ts
console.log("Hello from TypeScript backend!");
console.log(`Node.js ${process.version}`);
console.log(`Running in ${process.env.NODE_ENV ?? "development"} mode`);npm run dev
# Output:
# Hello from TypeScript backend!
# Node.js v22.x.x
# Running in development mode5. Build Process
Development uses tsx for speed (no compilation step). Production needs compiled JavaScript. Here are three approaches.
Option 1: tsc (Standard)
The TypeScript compiler. Simple, reliable, officially supported:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
}
}Pros: Full type-checking during build, generates declaration files, source maps Cons: Slower than alternatives (5-10x)
Option 2: esbuild (Fast)
Blazing fast bundler written in Go:
npm install -D esbuild{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external",
"start": "node dist/index.js"
}
}Key flags:
--bundle: Bundles all your code into fewer files--platform=node: Targets Node.js (not browser)--packages=external: Keepsnode_modulesas external imports (don't bundle dependencies)--format=esm: Output ESM format
Pros: 10-100x faster than tsc, bundles code, tree-shakes dead code
Cons: No type-checking (pair with tsc --noEmit in CI)
Option 3: swc (Fast + Configurable)
Rust-based compiler, used by Next.js:
npm install -D @swc/cli @swc/coreCreate .swcrc:
{
"$schema": "https://swc.rs/schema.json",
"module": {
"type": "es6"
},
"jsc": {
"target": "es2022",
"parser": {
"syntax": "typescript",
"decorators": true
},
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}{
"scripts": {
"build": "swc src -d dist --strip-leading-paths",
"start": "node dist/index.js"
}
}Recommended: Hybrid Approach
Use the best of both worlds — fast builds with type safety:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
}
}This runs type-checking (tsc --noEmit) first, then builds with esbuild if types pass. You get both safety and speed.
6. Project Structure
A well-organized project structure is critical for maintainability. Here's a battle-tested layout for backend projects:
my-api/
├── src/
│ ├── index.ts # Entry point - starts the server
│ ├── app.ts # Express/Fastify app setup
│ ├── config/
│ │ └── env.ts # Environment variable validation
│ ├── routes/
│ │ ├── index.ts # Route aggregator
│ │ ├── users.ts # /api/users routes
│ │ └── posts.ts # /api/posts routes
│ ├── controllers/
│ │ ├── user.controller.ts
│ │ └── post.controller.ts
│ ├── services/
│ │ ├── user.service.ts
│ │ └── post.service.ts
│ ├── repositories/
│ │ ├── user.repository.ts
│ │ └── post.repository.ts
│ ├── middleware/
│ │ ├── auth.ts # Authentication middleware
│ │ ├── error-handler.ts # Global error handler
│ │ └── validate.ts # Request validation
│ ├── types/
│ │ ├── index.ts # Shared types
│ │ └── express.d.ts # Express type extensions
│ └── utils/
│ ├── logger.ts # Logging utility
│ └── errors.ts # Custom error classes
├── tests/
│ ├── unit/
│ └── integration/
├── .env # Local environment variables
├── .env.example # Template for environment variables
├── .gitignore
├── .prettierrc
├── eslint.config.mjs
├── nodemon.json
├── package.json
└── tsconfig.jsonThe Layered Architecture
This structure follows the Controller → Service → Repository pattern:
Routes — Define endpoints and connect them to controllers. No business logic here.
Controllers — Handle HTTP concerns (parse request, send response). Delegates to services.
Services — Business logic lives here. Framework-agnostic. Testable in isolation.
Repositories — Database access layer. SQL queries, ORM calls. Services don't know about SQL.
Example of how they connect:
// src/routes/users.ts
import { Router } from "express";
import { UserController } from "@/controllers/user.controller.js";
const router = Router();
const controller = new UserController();
router.get("/", controller.getAll);
router.get("/:id", controller.getById);
router.post("/", controller.create);
export default router;// src/controllers/user.controller.ts
import type { Request, Response, NextFunction } from "express";
import { UserService } from "@/services/user.service.js";
export class UserController {
private service = new UserService();
getAll = async (_req: Request, res: Response, next: NextFunction) => {
try {
const users = await this.service.findAll();
res.json(users);
} catch (error) {
next(error);
}
};
getById = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.service.findById(req.params.id!);
if (!user) {
res.status(404).json({ error: "User not found" });
return;
}
res.json(user);
} catch (error) {
next(error);
}
};
create = async (req: Request, res: Response, next: NextFunction) => {
try {
const user = await this.service.create(req.body);
res.status(201).json(user);
} catch (error) {
next(error);
}
};
}// src/services/user.service.ts
import { UserRepository } from "@/repositories/user.repository.js";
export class UserService {
private repository = new UserRepository();
async findAll() {
return this.repository.findMany();
}
async findById(id: string) {
return this.repository.findById(id);
}
async create(data: { name: string; email: string }) {
// Business logic: validate, check duplicates, etc.
const existing = await this.repository.findByEmail(data.email);
if (existing) {
throw new Error("Email already exists");
}
return this.repository.create(data);
}
}Why This Separation Matters
You might think this is overkill for a small project. Here's why it pays off:
- Testing: Services can be tested without HTTP, repositories without business logic
- Swapping: Replace Express with Fastify? Only routes and controllers change
- Readability: New developers know exactly where to find things
- Scaling: When a file gets too big, the boundaries are already drawn
7. Environment Variables
Never hardcode secrets. Never trust process.env without validation.
The Problem with Raw process.env
// BAD: No validation, no types, crashes at runtime
const port = process.env.PORT; // string | undefined
const dbUrl = process.env.DATABASE_URL; // might not exist!
// Your app starts, connects to... nothing. Crashes 5 minutes later
// when the first database query runs.Type-Safe Environment with Zod
Install Zod:
npm install zodCreate src/config/env.ts:
// src/config/env.ts
import { z } from "zod";
const envSchema = z.object({
// Server
PORT: z.coerce.number().default(3000),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
HOST: z.string().default("0.0.0.0"),
// Database
DATABASE_URL: z.string().url("DATABASE_URL must be a valid URL"),
// Auth
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
JWT_EXPIRES_IN: z.string().default("7d"),
// External services (optional in development)
REDIS_URL: z.string().url().optional(),
SMTP_HOST: z.string().optional(),
SMTP_PORT: z.coerce.number().optional(),
});
// Validate on import — fail fast if env is misconfigured
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("❌ Invalid environment variables:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
// Type is automatically inferred:
// {
// PORT: number;
// NODE_ENV: "development" | "production" | "test";
// HOST: string;
// DATABASE_URL: string;
// JWT_SECRET: string;
// JWT_EXPIRES_IN: string;
// REDIS_URL?: string;
// SMTP_HOST?: string;
// SMTP_PORT?: number;
// }Usage Throughout Your App
// src/index.ts
import { env } from "@/config/env.js";
// Fully typed! IDE autocomplete works.
console.log(`Starting server on port ${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
// env.PORT is number (not string!)
// env.NODE_ENV is "development" | "production" | "test" (not string)Create .env.example
# .env.example — Copy to .env and fill in values
PORT=3000
NODE_ENV=development
HOST=0.0.0.0
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
JWT_SECRET=your-secret-key-at-least-32-characters-long
JWT_EXPIRES_IN=7d
# Optional
REDIS_URL=redis://localhost:6379Don't Forget .gitignore
# Environment
.env
.env.local
.env.*.local
# Build output
dist/
# Dependencies
node_modules/
# IDE
.vscode/
.idea/
# OS
.DS_Store8. Path Aliases
Path aliases replace long relative imports with clean, absolute-like paths:
// WITHOUT aliases (painful)
import { UserService } from "../../../services/user.service.js";
import { env } from "../../../../config/env.js";
// WITH aliases (clean)
import { UserService } from "@/services/user.service.js";
import { env } from "@/config/env.js";Step 1: Configure TypeScript
Already done in our tsconfig.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Step 2: Make It Work at Runtime
TypeScript understands path aliases for type-checking, but Node.js doesn't. You need a runtime resolver.
For development (tsx): Path aliases work out of the box. No extra config needed.
For production (compiled JS): You have two options.
Option A: tsc-alias (post-compile)
npm install -D tsc-alias{
"scripts": {
"build": "tsc && tsc-alias"
}
}tsc-alias rewrites the import paths in the compiled .js files from @/ to relative paths.
Option B: esbuild handles it automatically
If you're using esbuild for building, it resolves path aliases during bundling. No extra step needed — just point it to your tsconfig.json:
{
"scripts": {
"build": "esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external --tsconfig=tsconfig.json"
}
}9. Complete package.json
Here's the full package.json tying everything together:
{
"name": "my-api",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc --noEmit && esbuild src/index.ts --bundle --platform=node --outdir=dist --format=esm --packages=external --tsconfig=tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write 'src/**/*.ts'"
},
"devDependencies": {
"@eslint/js": "^9.0.0",
"@swc/cli": "^0.5.0",
"@swc/core": "^1.9.0",
"@types/node": "^22.0.0",
"esbuild": "^0.24.0",
"eslint": "^9.0.0",
"eslint-config-prettier": "^10.0.0",
"prettier": "^3.4.0",
"tsx": "^4.19.0",
"typescript": "^5.7.0",
"typescript-eslint": "^8.0.0"
},
"dependencies": {
"zod": "^3.24.0"
}
}10. Verification: Hello World API
Let's verify everything works end-to-end with a minimal Express server:
npm install express
npm install -D @types/express// src/index.ts
import express from "express";
import { env } from "@/config/env.js";
const app = express();
app.use(express.json());
app.get("/", (_req, res) => {
res.json({
message: "Hello from TypeScript!",
environment: env.NODE_ENV,
timestamp: new Date().toISOString(),
});
});
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.listen(env.PORT, env.HOST, () => {
console.log(`Server running at http://${env.HOST}:${env.PORT}`);
console.log(`Environment: ${env.NODE_ENV}`);
});Create a .env file:
PORT=3000
NODE_ENV=development
HOST=0.0.0.0
DATABASE_URL=postgresql://localhost:5432/mydb
JWT_SECRET=my-development-secret-key-at-least-32-charsRun it:
npm run dev
# Output:
# Server running at http://0.0.0.0:3000
# Environment: developmentTest it:
curl http://localhost:3000
# {"message":"Hello from TypeScript!","environment":"development","timestamp":"2026-03-01T..."}
curl http://localhost:3000/health
# {"status":"ok"}Build and run production:
npm run build
npm startSummary
Here's what we set up and why:
| Component | Tool | Why |
|---|---|---|
| TypeScript config | tsconfig.json | Strict types, ES2022, NodeNext modules |
| Linting | ESLint 9 + typescript-eslint | Catch async bugs, enforce consistency |
| Formatting | Prettier | Consistent code style, zero debates |
| Dev runner | tsx watch | Fast execution, auto-reload, zero config |
| Build | tsc --noEmit + esbuild | Type safety + fast bundling |
| Env vars | Zod validation | Fail fast, typed config, no surprises |
| Path aliases | @/* → ./src/* | Clean imports, no ../../../ |
| Structure | Controller/Service/Repository | Separation of concerns, testable layers |
What's Next?
This setup is the foundation for everything that follows. In the next post, we'll build a full REST API on top of this:
- Express.js routing with type-safe request/response
- Input validation with Zod
- Error handling middleware
- Controller/Service/Repository pattern in action
- API documentation with Swagger
Series: TypeScript Full-Stack Development
Previous: TypeScript Full-Stack Monorepo
Next: TypeScript: Build REST API with Express.js (coming soon)
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.