Chapter 43: Building
Building real Node.js applications in 2025–2026.
I will explain it as if we are sitting together:
- I open VS Code
- I create files one by one
- I explain why we choose this structure / pattern / library
- I show realistic decisions that experienced developers make today
- I warn about the most common beginner and intermediate traps
- I give you copy-paste-ready code that actually works in current Node.js (v20–v22 LTS)
We will build a realistic, production-grade REST API from scratch — step by step — using modern patterns that are popular right now.
Project Goal
We will create a Task Management API with:
- User registration & login (JWT)
- CRUD operations on tasks
- Proper error handling
- Input validation (Zod)
- TypeScript
- ESM
- Environment variables
- Logging
- Graceful shutdown
- Linting & formatting
This is the kind of foundation many real companies start with.
Step 1 – Project initialization & folder structure
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
mkdir task-api cd task-api # Initialize with ESM npm init -y npm pkg set type=module # Install core runtime dependencies npm install express cors helmet dotenv zod jsonwebtoken bcryptjs # Install development dependencies npm install -D \ typescript @types/node @types/express @types/cors @types/jsonwebtoken @types/bcryptjs \ tsx nodemon \ eslint prettier eslint-config-standard-with-typescript \ @typescript-eslint/parser @typescript-eslint/eslint-plugin \ lint-staged husky |
Recommended folder structure (2025–2026 style – layered + feature-ish)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
task-api/ ├── src/ │ ├── config/ ← env, constants, config validation │ │ ├── env.ts │ │ └── index.ts │ ├── controllers/ ← HTTP handlers (thin – call services) │ │ └── task.controller.ts │ ├── services/ ← business logic (core of the app) │ │ └── task.service.ts │ ├── middleware/ ← auth, validation, error handling │ │ ├── auth.middleware.ts │ │ └── error.middleware.ts │ ├── routes/ ← route definitions │ │ └── task.routes.ts │ ├── types/ ← shared types, DTOs │ │ └── task.types.ts │ ├── utils/ ← helpers │ │ └── logger.ts │ └── index.ts ← entry point ├── prisma/ ← if using Prisma (optional) ├── .env ├── .env.example ├── tsconfig.json ├── package.json └── README.md |
Why this structure?
- Separation of concerns (controllers thin, services fat)
- Easy to test (services are pure functions)
- Scalable (add feature folders later: /features/tasks/, /features/users/)
- Matches patterns used by NestJS, Fastify, Hono, Adonis, etc.
Step 2 – Core configuration files
tsconfig.json (modern & strict)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, "noImplicitAny": true, "skipLibCheck": true, "outDir": "./dist", "rootDir": "./src", "sourceMap": true, "noEmitOnError": true }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] } |
.prettierrc
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
{ "semi": false, "singleQuote": true, "trailingComma": "es5", "printWidth": 100, "tabWidth": 2, "useTabs": false, "bracketSpacing": true } |
eslint.config.mjs (ESLint 9 flat config)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
import js from '@eslint/js' import ts from 'typescript-eslint' export default [ js.configs.recommended, ...ts.configs.recommended, ...ts.configs.stylistic, { files: ['**/*.ts'], languageOptions: { parserOptions: { project: true, tsconfigRootDir: import.meta.dirname } }, rules: { '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-misused-promises': 'error', '@typescript-eslint/consistent-type-imports': 'error', '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 'no-console': ['warn', { allow: ['warn', 'error'] }] } }, { ignores: ['dist/**', 'node_modules/**'] } ] |
package.json scripts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
"scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", "build": "tsc", "lint": "eslint . --ext .ts", "lint:fix": "eslint . --ext .ts --fix", "format": "prettier --write .", "typecheck": "tsc --noEmit" } |
Step 3 – Environment & config validation (Zod)
src/config/env.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
import { z } from 'zod' import 'dotenv/config' const envSchema = z.object({ PORT: z.coerce.number().default(5000), NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters') }) const env = envSchema.parse(process.env) export default env |
Why Zod here?
- Runtime validation (TypeScript types alone are compile-time only)
- Beautiful error messages
- Automatic type inference (typeof env is correct)
Step 4 – Basic server setup
src/index.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
import express, { Request, Response, NextFunction } from 'express' import cors from 'cors' import helmet from 'helmet' import env from './config/env.js' import { errorHandler } from './middleware/error.middleware.js' import taskRoutes from './routes/task.routes.js' const app = express() // Security & parsing app.use(helmet()) app.use(cors()) app.use(express.json()) // Routes app.use('/api/tasks', taskRoutes) // Health check app.get('/health', (req: Request, res: Response) => { res.json({ status: 'ok', uptime: process.uptime(), environment: env.NODE_ENV }) }) // Error handling (must be last) app.use(errorHandler) app.listen(env.PORT, () => { console.log(`Server running → http://localhost:${env.PORT}`) console.log(`Environment: ${env.NODE_ENV}`) }) |
Step 5 – Custom error handling (production must-have)
src/middleware/error.middleware.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
import { Request, Response, NextFunction } from 'express' export class AppError extends Error { constructor( public statusCode: number, message: string, public isOperational = true ) { super(message) this.name = 'AppError' } } export function errorHandler( err: Error, req: Request, res: Response, next: NextFunction ) { if (err instanceof AppError) { return res.status(err.statusCode).json({ success: false, message: err.message, ...(env.NODE_ENV !== 'production' && { stack: err.stack }) }) } // Unknown error console.error('Unhandled error:', err) return res.status(500).json({ success: false, message: 'Internal server error' }) } |
Example usage in controller
|
0 1 2 3 4 5 6 7 8 |
if (!task) { throw new AppError(404, 'Task not found') } |
Step 6 – Validation & DTOs with Zod
src/types/task.types.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
import { z } from 'zod' export const createTaskSchema = z.object({ title: z.string().min(1).max(100), description: z.string().max(500).optional(), dueDate: z.string().datetime().optional(), priority: z.enum(['low', 'medium', 'high']).default('medium'), completed: z.boolean().default(false) }) export const updateTaskSchema = createTaskSchema.partial() export type CreateTaskInput = z.infer<typeof createTaskSchema> export type UpdateTaskInput = z.infer<typeof updateTaskSchema> |
src/controllers/task.controller.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { Request, Response } from 'express' import { createTaskSchema } from '../types/task.types.js' import * as taskService from '../services/task.service.js' import { AppError } from '../middleware/error.middleware.js' export const createTask = async (req: Request, res: Response) => { const input = createTaskSchema.parse(req.body) const task = await taskService.createTask(input, req.user!.id) res.status(201).json(task) } |
Step 7 – Summary – Modern Node.js + TypeScript project feel (2025–2026)
You now have:
- ESM + top-level await
- Strict TypeScript
- Zod runtime validation
- Proper error handling with custom classes
- Clean separation (controllers thin, services fat)
- Automatic formatting & linting on save/commit
- Environment validation
This is the foundation used by most serious Node.js backends today.
Which direction would you like to go next?
- Add JWT authentication + middleware
- Connect Prisma or Drizzle with full typing
- Implement rate limiting, logging (pino), helmet tweaks
- Add unit & integration testing with Vitest
- Docker + production deployment checklist
Just tell me what you want to build or learn next — I’ll continue with complete, realistic code. 😊
