Chapter 44: Applications
1. What does “Node.js Application” really mean in practice?
When people say “Node.js application”, they almost always mean one of these categories:
| Type | Typical use case | Main frameworks/libraries in 2025–2026 | Approx. % of Node.js projects |
|---|---|---|---|
| REST / GraphQL API | Backend for web/mobile apps | Express, Fastify, NestJS, Hono, Elysia | ~70–80% |
| Real-time / WebSocket app | Chat, live notifications, collaboration | Socket.IO, ws, uWebSockets, Fastify + ws | ~10–15% |
| CLI tool / dev utility | create-app, migration tools, generators | Commander.js, yargs, cac, ink (TUI) | ~5–10% |
| Background worker / queue | Image processing, email sending, cron jobs | BullMQ, Bee-Queue, Agenda, node-cron | ~5–10% |
| Serverless function | Vercel, Netlify, AWS Lambda, Cloudflare | hono, itty-router, workerd | growing very fast |
| Monorepo tooling | turbo, nx, lage, moon | — | in larger teams |
Most beginners start with #1 (REST API) — and that is what most tutorials teach.
Most real money is made with #1 + #3 + #4 (API + CLI + workers).
We will build a modern REST API step-by-step — this is the foundation that almost everything else builds on.
2. Project structure – what actually works in 2025–2026
There are several popular styles. Here are the ones you see most often:
Style A – Layered / Clean Architecture (most common in medium/large teams)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
src/ ├── config/ ← env, constants, validation schemas ├── controllers/ ← thin HTTP handlers (just call services) ├── dtos/ ← input/output shapes (often Zod schemas) ├── middleware/ ← auth, validation, error, rate-limit ├── routes/ ← route definitions + versioning ├── services/ ← business logic (core of the app) ├── repositories/ ← data access layer (if not using ORM directly) ├── utils/ ← helpers, logger, date, crypto... ├── types/ ← shared types, enums, branded types └── index.ts ← entry point (wires everything) |
Style B – Feature folders (popular with vertical slice architecture)
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
src/ ├── features/ │ ├── auth/ │ │ ├── auth.controller.ts │ │ ├── auth.service.ts │ │ ├── auth.routes.ts │ │ └── auth.types.ts │ ├── tasks/ │ │ ├── task.controller.ts │ │ ├── task.service.ts │ │ ├── task.routes.ts │ │ └── task.types.ts └── shared/ ← config, middleware, utils, types |
Which one to choose?
- Layered → easier for teams > 5 people, easier to enforce architecture
- Feature folders → easier to delete whole features, better for micro-frontends mindset
Recommendation for learning / medium projects (2025–2026): start with layered — it is easier to understand and most tutorials/books use it.
We will use layered style in this tutorial.
3. Realistic modern package.json (2025–2026)
|
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 40 41 42 43 44 45 46 47 48 49 |
{ "name": "task-api", "version": "1.0.0", "private": true, "type": "module", "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", "test": "vitest run", "test:watch": "vitest" }, "dependencies": { "express": "^4.19.2", "zod": "^3.23.8", "jsonwebtoken": "^9.0.2", "bcryptjs": "^2.4.3", "cors": "^2.8.5", "helmet": "^7.1.0", "dotenv": "^16.4.5" }, "devDependencies": { "@types/express": "^4.17.21", "@types/node": "^20.14.10", "@types/jsonwebtoken": "^9.0.6", "@types/bcryptjs": "^2.4.6", "typescript": "^5.5.4", "tsx": "^4.19.0", "nodemon": "^3.1.7", "eslint": "^9.9.0", "prettier": "^3.3.3", "eslint-config-standard-with-typescript": "^43.0.1", "@typescript-eslint/parser": "^8.0.0", "@typescript-eslint/eslint-plugin": "^8.0.0", "vitest": "^2.0.5" }, "engines": { "node": ">=20.11.0" } } |
4. Creating the first working API (step by step)
src/index.ts – entry point
|
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 express, { Express, Request, Response } from 'express' import cors from 'cors' import helmet from 'helmet' import 'dotenv/config' import env from './config/env.js' import { errorHandler } from './middleware/error.middleware.js' import taskRoutes from './routes/task.routes.js' const app: Express = 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 || 'development' }) }) // Global error handler – must be last app.use(errorHandler) app.listen(env.PORT, () => { console.log(`Server running → http://localhost:${env.PORT}`) console.log(`Environment: ${env.NODE_ENV || 'development'}`) }) |
src/config/env.ts – safe environment variables
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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'), JWT_SECRET: z.string().min(32, 'JWT_SECRET is required and must be at least 32 characters') }) const env = envSchema.parse(process.env) export default env |
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' import env from '../config/env.js' 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 }) }) } console.error('Unhandled error:', err) return res.status(500).json({ success: false, message: 'Internal server error' }) } |
src/routes/task.routes.ts – simple example
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import { Router } from 'express' import * as taskController from '../controllers/task.controller.js' const router = Router() router.get('/', taskController.getAllTasks) router.post('/', taskController.createTask) router.get('/:id', taskController.getTaskById) router.put('/:id', taskController.updateTask) router.delete('/:id', taskController.deleteTask) export default router |
src/controllers/task.controller.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 |
import { Request, Response } from 'express' import * as taskService from '../services/task.service.js' import { createTaskSchema } from '../types/task.types.js' import { AppError } from '../middleware/error.middleware.js' export const getAllTasks = async (req: Request, res: Response) => { const tasks = await taskService.getAllTasks() res.json(tasks) } export const createTask = async (req: Request, res: Response) => { const input = createTaskSchema.parse(req.body) const task = await taskService.createTask(input) res.status(201).json(task) } export const getTaskById = async (req: Request<{ id: string }>, res: Response) => { const task = await taskService.getTaskById(req.params.id) if (!task) throw new AppError(404, 'Task not found') res.json(task) } |
src/types/task.types.ts
|
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
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') }) export type CreateTaskInput = z.infer<typeof createTaskSchema> |
src/services/task.service.ts – fake in-memory for now
|
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 |
import { randomUUID } from 'node:crypto' import { CreateTaskInput } from '../types/task.types.js' type Task = CreateTaskInput & { id: string createdAt: string updatedAt: string } const tasks: Task[] = [] export const getAllTasks = async (): Promise<Task[]> => { return tasks } export const createTask = async (input: CreateTaskInput): Promise<Task> => { const task: Task = { id: randomUUID(), ...input, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() } tasks.push(task) return task } export const getTaskById = async (id: string): Promise<Task | null> => { return tasks.find(t => t.id === id) ?? null } |
Summary – What you now have
You have a modern, type-safe, production-ready foundation:
- ESM + top-level await
- Strict TypeScript
- Zod runtime validation
- Custom error handling
- Layered architecture
- Automatic linting & formatting
- Environment validation
This structure is used (with small variations) by many real teams building serious Node.js backends today.
Which part would you like to extend / improve next?
- Add JWT authentication + protected routes
- Connect real database (Prisma / Drizzle)
- Add rate limiting, logging (pino), request tracing
- Implement unit & integration tests with Vitest
- Add Dockerfile + docker-compose
- Deploy checklist (Render, Railway, Fly.io, Vercel)
Just tell me what you want to build or understand next — I’ll continue with complete, realistic code. 😊
