Most tutorials teach you to build a Node.js server from scratch by pasting six lines of Express and calling it a day. You end up with a running process and zero understanding of what is actually happening. This guide takes the opposite route: we start with the native http module, hit its limits on purpose, and only then bring in Express 5 so you can see exactly what the framework abstracts away.
By the end you will have a working API, a clean environment configuration layer, auto-restart during development, and a folder structure you can actually deploy.
The mental model: what a Node.js server really is
Before writing a single line of code, internalise this: a Node.js server is a long-running process that keeps a TCP socket open on a port and runs a callback every time bytes arrive on it.
That is the whole idea. Three consequences follow, and they explain 90% of the confusion beginners have:
- The process does not exit. A normal Node script runs and terminates. A server stays alive because the listening socket keeps the event loop busy. That is why your terminal “hangs” after you start it, and that is correct behaviour.
- One process, one thread, one event loop. Node handles thousands of concurrent connections not by creating threads, but by never blocking. Any synchronous CPU-heavy work you write freezes every other request.
- State lives in RAM until you restart. Variables declared at module level are shared across all requests and are wiped on every restart or deploy.
Express, Fastify, Koa and friends are not servers. They are request handlers that plug into the same http module you are about to use directly.

Prerequisites and version check
As of September 2026, use Node.js 24 LTS (Active LTS) for anything you intend to run in production. Node.js 26 is the current release line and becomes LTS in October 2026, so it is fine for experimenting but not yet the default choice for a production API.
Check what you have:
node -v
npm -v
If node -v prints something older than v22, upgrade. Everything in this tutorial relies on modern features (ES modules, the global fetch, node --watch, --env-file) that assume a recent runtime. Use nvm or fnm so you can switch versions per project instead of installing Node globally once and suffering for two years.
Step 1: Initialise the project
Create the folder and generate a package.json:
mkdir node-server-scratch
cd node-server-scratch
npm init -y
Now open package.json and add one crucial line:
{
"name": "node-server-scratch",
"version": "1.0.0",
"type": "module",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js"
}
}
"type": "module" switches the project to ES modules, so you write import instead of require. This is the default style for new Node projects in 2026 and it avoids the mixed-syntax mess you find in older tutorials.
Create the source folder:
mkdir src
touch src/server.js
Step 2: Your first server with the native http module
No dependencies. Nothing installed. Just Node. Put this in src/server.js:
import http from 'node:http';
const PORT = 3000;
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Hello from a raw Node.js server');
});
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
Run it:
npm start
Open http://localhost:3000 in a browser, or from a second terminal:
curl -i http://localhost:3000
Stop it with Ctrl + C.
What each piece does
| Element | Role |
|---|---|
node:http |
Built-in module. The node: prefix makes it explicit that this is a core module, not an npm package with the same name. |
createServer(cb) |
Creates the server object and registers the callback fired on every incoming request. |
req |
A readable stream carrying method, URL, headers and body. |
res |
A writable stream. Nothing is sent to the client until you write to it, and the connection stays open until res.end(). |
listen(PORT) |
Binds the socket. This is the line that turns a script into a server process. |
Notice what is missing: there is no routing. Every URL, every HTTP method, every path returns the same plain text. That is the honest baseline.

Step 3: Add routing by hand
Let’s build a tiny API with the raw module: a health endpoint, a users endpoint that accepts JSON, and a proper 404.
import http from 'node:http';
const PORT = 3000;
const server = http.createServer((req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
const json = (status, payload) => {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
};
if (req.method === 'GET' && url.pathname === '/api/health') {
return json(200, { status: 'ok', uptime: process.uptime() });
}
if (req.method === 'POST' && url.pathname === '/api/users') {
let raw = '';
req.on('data', (chunk) => {
raw += chunk;
if (raw.length > 1e6) req.destroy();
});
req.on('end', () => {
try {
const body = JSON.parse(raw);
if (!body.name) return json(422, { error: 'name is required' });
return json(201, { id: crypto.randomUUID(), name: body.name });
} catch {
return json(400, { error: 'Invalid JSON body' });
}
});
return;
}
json(404, { error: 'Not found', path: url.pathname });
});
server.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
Test the POST route:
curl -X POST http://localhost:3000/api/users \
-H "Content-Type: application/json" \
-d '{"name":"Ada"}'
This works. It is also where the pain starts:
- You are reading the request body chunk by chunk, manually, on every route that needs a body.
- Route matching is a growing chain of
ifstatements. Add URL parameters like/api/users/42and you are writing regular expressions. - There is no shared logic layer. Want logging, CORS, or auth on 12 routes? Copy and paste 12 times.
- A thrown error inside a callback crashes the whole process instead of returning a 500.
- Serving a static file means reading from disk, guessing MIME types and handling stream errors yourself.
Every single one of those items is a feature Express gives you. Now you know why it exists instead of just believing it.
What Express actually abstracts away
| Task | Native http module | Express |
|---|---|---|
| Routing | Manual if / switch on method and pathname |
app.get('/users/:id', handler) |
| URL parameters | Regex or string splitting | req.params.id |
| JSON body | Stream events plus JSON.parse plus try/catch |
express.json(), then req.body |
| Query string | new URL() plus searchParams |
req.query |
| Responses | writeHead plus end plus manual headers |
res.status(201).json({}) |
| Cross-cutting logic | Copy and paste | Middleware chain |
| Static files | fs.createReadStream plus MIME mapping |
express.static('public') |
| Error handling | try/catch everywhere or crash | Single error middleware |
Key point: Express still calls http.createServer() under the hood. It replaces the request callback, nothing more.
Step 4: Rebuild the same server with Express 5
Install it. Express 5 is the current stable major, and it is what npm install express gives you today:
npm install express
Rewrite src/server.js:
import express from 'express';
const app = express();
const PORT = 3000;
app.use(express.json());
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.post('/api/users', (req, res) => {
const { name } = req.body;
if (!name) return res.status(422).json({ error: 'name is required' });
res.status(201).json({ id: crypto.randomUUID(), name });
});
app.listen(PORT, () => {
console.log(`Server listening on http://localhost:${PORT}`);
});
Same behaviour, roughly a third of the code, and now adding /api/users/:id is one line instead of a regex. Two things worth remembering from Express 5:
- Async errors are forwarded automatically. If an
asynchandler rejects, Express 5 passes the error to your error middleware. In Express 4 that silently killed the request. - Body parsers are built in. No separate
body-parserpackage needed.

Step 5: Routers and middleware, the right way
A single file stops scaling around 150 lines. Split routes into a router module.
src/routes/users.routes.js:
import { Router } from 'express';
const router = Router();
const users = [{ id: '1', name: 'Ada' }];
router.get('/', (req, res) => {
res.json(users);
});
router.get('/:id', (req, res) => {
const user = users.find((u) => u.id === req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
});
router.post('/', (req, res) => {
const { name } = req.body;
if (!name) return res.status(422).json({ error: 'name is required' });
const user = { id: crypto.randomUUID(), name };
users.push(user);
res.status(201).json(user);
});
export default router;
Understanding middleware in one paragraph
A middleware is a function (req, res, next) that runs in the order you register it. It can read or modify the request, send a response and stop the chain, or call next() to pass control along. That is the entire concept. A request travels down the stack until something responds.
src/middleware/logger.js:
export function requestLogger(req, res, next) {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
}
Step 6: Centralised error handling and 404
An error middleware is identified by its four arguments. Register it last, after all routes.
src/middleware/error.js:
export function notFound(req, res) {
res.status(404).json({ error: 'Route not found', path: req.originalUrl });
}
export function errorHandler(err, req, res, next) {
const status = err.status || 500;
const isProd = process.env.NODE_ENV === 'production';
console.error(err);
res.status(status).json({
error: status === 500 && isProd ? 'Internal server error' : err.message
});
}
Never leak stack traces to clients in production. That is one of the most common security mistakes in small Node APIs.
Step 7: Environment configuration with dotenv
Hardcoding const PORT = 3000 is fine for a demo and unacceptable anywhere else. Your port, database URL, API keys and log level must come from the environment.
Option A: dotenv (works everywhere)
npm install dotenv
Create .env at the project root:
NODE_ENV=development
PORT=3000
API_KEY=local-dev-key
LOG_LEVEL=debug
Create .env.example with the same keys and no real values. Commit that one, never the real .env.
.gitignore:
node_modules
.env
.env.local
*.log
Option B: the native –env-file flag
Modern Node can load a .env file without any package:
node --env-file=.env src/server.js
Use this when you want zero dependencies. Use dotenv when you need extras such as variable expansion, multiple cascading files or encrypted secrets.
Validate your config once, at boot
Do not scatter process.env.WHATEVER across 40 files. Read it once, validate, export a frozen object. src/config/index.js:
import 'dotenv/config';
function required(key, fallback) {
const value = process.env[key] ?? fallback;
if (value === undefined) {
throw new Error(`Missing required environment variable: ${key}`);
}
return value;
}
export const config = Object.freeze({
env: required('NODE_ENV', 'development'),
port: Number(required('PORT', 3000)),
apiKey: required('API_KEY'),
logLevel: required('LOG_LEVEL', 'info'),
isProd: process.env.NODE_ENV === 'production'
});
Why this matters: a missing variable now crashes the process on startup with a clear message, instead of producing a mysterious undefined three days later in production.

Step 8: Split app from server
This separation is what makes your server testable. The app knows nothing about ports.
src/app.js:
import express from 'express';
import usersRouter from './routes/users.routes.js';
import { requestLogger } from './middleware/logger.js';
import { notFound, errorHandler } from './middleware/error.js';
export function createApp() {
const app = express();
app.use(express.json());
app.use(requestLogger);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.use('/api/users', usersRouter);
app.use(notFound);
app.use(errorHandler);
return app;
}
src/server.js:
import { createApp } from './app.js';
import { config } from './config/index.js';
const app = createApp();
const server = app.listen(config.port, () => {
console.log(`[${config.env}] Server listening on http://localhost:${config.port}`);
});
function shutdown(signal) {
console.log(`${signal} received, closing server...`);
server.close(() => {
console.log('HTTP server closed');
process.exit(0);
});
setTimeout(() => process.exit(1), 10000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
That graceful shutdown block is what separates a toy script from a deployable service. Docker, Kubernetes and most PaaS platforms send SIGTERM before killing your container. Without it, in-flight requests are dropped on every deploy.
Step 9: Auto-restart during development
You have two solid options in 2026:
| Tool | Command | When to use it |
|---|---|---|
| node –watch | node --watch --env-file=.env src/server.js |
Built in, zero dependencies, perfect for most projects |
| nodemon | nodemon src/server.js |
When you need fine-grained config: watched extensions, ignore lists, delays, custom exec commands |
Install nodemon as a dev dependency, never globally:
npm install --save-dev nodemon
Optional nodemon.json:
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["src/**/*.test.js"],
"delay": "300"
}
Final package.json scripts:
{
"type": "module",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"dev:native": "node --watch --env-file=.env src/server.js",
"test": "node --test"
}
}
Run npm run dev while coding and npm start in production. Never ship nodemon to a production server.
Step 10: Production-ready folder structure
node-server-scratch/
├── src/
│ ├── config/
│ │ └── index.js # env parsing and validation
│ ├── middleware/
│ │ ├── logger.js
│ │ └── error.js
│ ├── routes/
│ │ └── users.routes.js # HTTP layer only
│ ├── controllers/
│ │ └── users.controller.js
│ ├── services/
│ │ └── users.service.js # business logic, no req/res
│ ├── utils/
│ ├── app.js # builds the Express app
│ └── server.js # binds the port, handles signals
├── public/ # static assets
├── tests/
├── .env
├── .env.example
├── .gitignore
├── package.json
└── README.md
The rule that keeps this structure healthy: routes handle HTTP, services handle logic. If a function references req or res, it belongs in the route or controller layer. If it does not, it belongs in a service, where it can be unit tested without starting a server. This write-up is worth a look.

Production checklist before you deploy
- Set
NODE_ENV=production. Express optimises view caching and error output based on it. - Bind to
process.env.PORT, never a hardcoded number. Hosting platforms inject the port. - Add helmet for security headers and
corsif a browser front end calls your API. - Add rate limiting on public endpoints.
- Keep a
/api/healthendpoint for load balancers and uptime monitors. - Run behind a process manager (PM2, systemd) or a container orchestrator so crashes restart automatically.
- Put Nginx, Caddy or your provider’s load balancer in front for TLS termination. Do not handle HTTPS certificates in Node unless you have a reason to.
- Log to stdout as structured JSON and let the platform collect it.
Common mistakes when building a Node server from scratch
- Forgetting
res.end()or a response call. The request hangs until timeout. Every code path must respond exactly once. - Registering the error middleware before the routes. Order matters. It goes last, always.
- Blocking the event loop with synchronous file reads, giant loops or
JSON.parseon huge payloads. One slow request freezes all of them. - Committing
.env. Add it to.gitignoreon day one, not after a leak. - EADDRINUSE errors. A previous process is still holding the port. Kill it or change the port.
- Mixing
requireandimportin a project without a clear"type"field.
Where to go next
You now have the mental model, not just the boilerplate. Natural next steps: connect a database with a repository layer, add JWT authentication as middleware, write tests with the built-in node --test runner, and containerise the app with a multi-stage Dockerfile. Every one of those slots cleanly into the structure above, which is exactly the point of building it this way.
FAQ
How do I create a Node.js server?
Run npm init -y, create a file, import the built-in http module, call http.createServer((req, res) => res.end('Hello')) and then server.listen(3000). That is a complete server with no dependencies. Add Express when you need routing, middleware and body parsing without writing them yourself.
How do I start a Node.js server?
From the project folder, run node src/server.js, or npm start if you defined the script. For development with auto-reload use npm run dev with nodemon, or the built-in node --watch src/server.js. Stop the process with Ctrl + C.
Is Node.js backend or frontend?
Node.js is a backend JavaScript runtime. It executes JavaScript outside the browser, on a server or on your machine. The confusion comes from the language: JavaScript runs on both sides, but Node itself has no DOM and no browser APIs. It is used for APIs, servers, CLI tools and build tooling.
Do I need Express to build a Node.js server?
No. The native http module is enough, and for a single-purpose microservice or a webhook receiver it may be the better choice. Express becomes worth it the moment you have several routes, shared middleware and JSON payloads, because you stop rewriting the same plumbing.
What is the difference between dotenv and node –env-file?
--env-file is a native Node flag that loads a .env file with zero dependencies. dotenv is an npm package that does the same and adds features such as variable expansion, multiple files and better tooling integration. For a simple project the native flag is enough.
How much does it cost to host a Node.js server?
A small API runs comfortably on a 1 GB VPS for roughly 4 to 8 USD per month. Serverless and PaaS platforms offer free tiers that cover hobby projects, then bill by request or by active instance hours. The main cost drivers are memory, always-on instances and outbound bandwidth, not Node itself, which is free and open source.
Which Node.js version should I use in 2026?
Node.js 24 LTS for production workloads. It receives security fixes into 2028. Node.js 26 is the current line and becomes LTS in late 2026, so plan the upgrade rather than rushing it.
Why does my server keep running after the script finishes?
Because listen() registers an active handle in the event loop. As long as the socket is open, Node has a reason to stay alive. That is what makes it a server rather than a script.
Need help designing, auditing or deploying a Node.js backend? The team at coding4.net builds and maintains production APIs for companies of all sizes. Get in touch and let’s talk about your project.

