Episode 3 — NodeJS MongoDB Backend Architecture / 3.4 — Express JS
3.4.b — Setting Up an Express Server
In one sentence: Creating an Express server takes five lines of code —
require,create app,define route,listen— but understanding every line and the surrounding tooling (environment variables, project structure, nodemon) is what separates copy-pasters from real developers.
Navigation: <- 3.4.a — What is Express.js? | 3.4.c — Returning Responses ->
1. Minimal Express server (every line explained)
// server.js
const express = require('express'); // 1. Import Express
const app = express(); // 2. Create application instance
const PORT = 3000; // 3. Define port number
// 4. Define a route
app.get('/', (req, res) => {
res.send('Hello, Express!');
});
// 5. Start listening
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Line-by-line breakdown
| Line | What it does |
|---|---|
const express = require('express') | Imports the Express module from node_modules. Returns a function. |
const app = express() | Calls that function, creating an Express application instance. This is your server. |
const PORT = 3000 | Stores the port number. Convention: uppercase for config constants. |
app.get('/', handler) | Registers a route — when a GET request hits /, run the handler function. |
(req, res) => { ... } | The route handler receives the request object and response object. |
res.send('Hello, Express!') | Sends a response body and ends the response. |
app.listen(PORT, callback) | Binds the server to the port and starts accepting connections. Callback runs once the server is ready. |
Run it
node server.js
# Output: Server running on http://localhost:3000
Open http://localhost:3000 in a browser — you see "Hello, Express!"
2. const app = express() — the application instance
When you call express(), you get an app object that:
- Has methods for every HTTP verb:
app.get(),app.post(),app.put(),app.delete(), etc. - Has
app.use()for registering middleware - Has
app.listen()to start the server - Stores all routes and middleware in an internal stack
- Is itself a function that can be passed to
http.createServer()(advanced usage)
const express = require('express');
const app = express();
// app is a function with methods attached
console.log(typeof app); // 'function'
console.log(typeof app.get); // 'function'
console.log(typeof app.use); // 'function'
console.log(typeof app.listen); // 'function'
Under the hood
// Express internally does something like:
const http = require('http');
app.listen = function(port, callback) {
const server = http.createServer(this); // 'this' is the app function
return server.listen(port, callback);
};
So app.listen() creates a raw http.Server and passes the Express app as the request handler.
3. app.listen(port, callback) — starting the server
// Basic usage
app.listen(3000, () => {
console.log('Server is running on port 3000');
});
// Listen on all interfaces (default)
app.listen(3000, '0.0.0.0', () => {
console.log('Accessible from network');
});
// Listen only on localhost
app.listen(3000, '127.0.0.1', () => {
console.log('Only accessible locally');
});
What app.listen() returns
It returns the underlying http.Server instance, which you can use for:
const server = app.listen(3000, () => {
console.log('Running');
});
// Graceful shutdown
process.on('SIGTERM', () => {
server.close(() => {
console.log('Server closed gracefully');
process.exit(0);
});
});
4. Environment variables for port
Never hardcode the port in production. Use environment variables:
// Best practice
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
| Context | How PORT is set |
|---|---|
| Local development | Falls back to 3000 (or whatever default you choose) |
| Heroku / Railway / Render | Platform injects process.env.PORT automatically |
| Docker | Set via -e PORT=8080 or docker-compose.yml |
.env file | Load with dotenv package |
Using dotenv
npm install dotenv
# .env (root of project — add to .gitignore!)
PORT=4000
NODE_ENV=development
// server.js — load .env at the very top
require('dotenv').config();
const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} in ${process.env.NODE_ENV} mode`);
});
5. Project structure for Express apps
Minimal structure (learning / small projects)
my-app/
node_modules/
package.json
package-lock.json
.env
.gitignore
server.js
Standard structure (real projects)
my-app/
node_modules/
src/
routes/
userRoutes.js
productRoutes.js
controllers/
userController.js
productController.js
middleware/
auth.js
errorHandler.js
models/
User.js
Product.js
config/
db.js
utils/
helpers.js
app.js <-- Express app setup (routes, middleware)
server.js <-- Entry point (just app.listen)
package.json
.env
.gitignore
Separation of app.js and server.js
This is a common best practice:
// src/app.js — Express app configuration
const express = require('express');
const app = express();
// Middleware
app.use(express.json());
// Routes
app.get('/', (req, res) => {
res.json({ message: 'API is running' });
});
module.exports = app;
// server.js — entry point (starting the server)
const app = require('./src/app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Why separate? Testing. You can import app in test files and use libraries like supertest without actually starting a server.
6. Your first route: app.get('/', handler)
app.get('/', (req, res) => {
res.send('Welcome to my API');
});
This tells Express: "When someone sends a GET request to /, run this function."
Route anatomy
app.get( '/path' , (req, res) => { ... } )
| | | | |
| | | | +-- handler body
| | | +-- req = request, res = response
| | +-- URL path to match
| +-- HTTP method (GET)
+-- Express app instance
Multiple routes
app.get('/', (req, res) => {
res.send('Home page');
});
app.get('/about', (req, res) => {
res.send('About page');
});
app.get('/contact', (req, res) => {
res.send('Contact page');
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
7. res.send() vs res.json() vs res.end()
| Method | Sets Content-Type | Use case |
|---|---|---|
res.send(string) | text/html | Sending HTML or plain text |
res.send(object) | application/json | Sending JSON (auto-detected) |
res.send(buffer) | application/octet-stream | Sending binary data |
res.json(object) | application/json | Always sends JSON — preferred for APIs |
res.end() | None (uses existing) | End response without body |
When to use each
// res.send — flexible, auto-detects content type
app.get('/html', (req, res) => {
res.send('<h1>Hello</h1>'); // Content-Type: text/html
});
app.get('/object', (req, res) => {
res.send({ name: 'Alice' }); // Content-Type: application/json
});
// res.json — explicit JSON, always sets application/json
// PREFERRED for APIs — intention is clear
app.get('/api/user', (req, res) => {
res.json({ id: 1, name: 'Alice' });
});
// res.json also converts non-objects (null, booleans, numbers)
app.get('/api/count', (req, res) => {
res.json(42); // Sends: 42 with application/json
});
// res.end — end with no body (rare in Express)
app.get('/ping', (req, res) => {
res.status(204).end(); // 204 No Content
});
Rule of thumb: Use res.json() for APIs. Use res.send() for HTML or when content type varies.
8. res.status() — setting status codes
res.status() sets the HTTP status code and returns res for chaining:
// Success responses
app.get('/api/users', (req, res) => {
res.status(200).json({ users: [] });
// 200 is the default, so this is equivalent:
// res.json({ users: [] });
});
// Created
app.post('/api/users', (req, res) => {
// ... create user ...
res.status(201).json({ id: 1, name: 'Alice' });
});
// Client errors
app.get('/api/users/:id', (req, res) => {
const user = null; // not found
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// Validation error
app.post('/api/users', (req, res) => {
if (!req.body.name) {
return res.status(400).json({ error: 'Name is required' });
}
// ...
});
// Server error
app.get('/api/data', (req, res) => {
try {
// ... risky operation ...
} catch (err) {
res.status(500).json({ error: 'Internal server error' });
}
});
Common status codes
| Code | Meaning | When to use |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST that creates a resource |
| 204 | No Content | Successful DELETE (no body returned) |
| 400 | Bad Request | Invalid input / validation error |
| 401 | Unauthorized | Missing or invalid authentication |
| 403 | Forbidden | Authenticated but no permission |
| 404 | Not Found | Resource does not exist |
| 500 | Internal Server Error | Unhandled server failure |
9. res.sendFile() — sending files
const path = require('path');
app.get('/download', (req, res) => {
res.sendFile(path.join(__dirname, 'files', 'report.pdf'));
});
// Serving an HTML file
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// With error handling
app.get('/file/:name', (req, res) => {
const filePath = path.join(__dirname, 'uploads', req.params.name);
res.sendFile(filePath, (err) => {
if (err) {
res.status(404).json({ error: 'File not found' });
}
});
});
Important: res.sendFile() requires an absolute path. Always use path.join(__dirname, ...).
10. Error handling basics
const express = require('express');
const app = express();
app.get('/api/users/:id', (req, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
// Simulate database lookup
const user = id === 1 ? { id: 1, name: 'Alice' } : null;
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
res.json(user);
});
// 404 catch-all — must be AFTER all routes
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Global error handler — must have 4 parameters
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Something went wrong' });
});
app.listen(3000);
Key rules
- Return after sending error responses — prevents "headers already sent" errors
- 404 catch-all goes after all routes (Express tries routes top-to-bottom)
- Error middleware has four parameters
(err, req, res, next)— Express identifies it by arity
11. Hot reloading with nodemon
Without nodemon, you must manually restart the server after every code change:
# Manual restart (tedious)
node server.js
# Make a change...
# Ctrl+C, then:
node server.js
Installing nodemon
# Install as dev dependency
npm install --save-dev nodemon
Using nodemon
# Run directly
npx nodemon server.js
# Or add to package.json scripts
{
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
}
}
# Now use:
npm run dev
What nodemon does
- Runs your script with Node
- Watches for file changes in your project directory
- Automatically restarts the server when
.js,.json, or.mjsfiles change - Shows restart output in the terminal
nodemon configuration
Create nodemon.json in your project root for custom settings:
{
"watch": ["src"],
"ext": "js,json",
"ignore": ["node_modules", "test"],
"delay": 1000
}
| Option | What it does |
|---|---|
watch | Directories to monitor |
ext | File extensions that trigger restart |
ignore | Directories/files to skip |
delay | Wait N ms before restarting (avoids rapid restarts) |
12. Putting it all together
A realistic minimal Express setup:
// server.js
require('dotenv').config();
const express = require('express');
const path = require('path');
const app = express();
const PORT = process.env.PORT || 3000;
// --- Middleware ---
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true })); // Parse form data
app.use(express.static(path.join(__dirname, 'public'))); // Serve static files
// --- Routes ---
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the API' });
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// --- 404 Handler ---
app.use((req, res) => {
res.status(404).json({ error: `Route ${req.originalUrl} not found` });
});
// --- Error Handler ---
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
// --- Start ---
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
// package.json (relevant parts)
{
"name": "my-express-app",
"version": "1.0.0",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"dotenv": "^16.4.0",
"express": "^4.21.0"
},
"devDependencies": {
"nodemon": "^3.1.0"
}
}
13. Key takeaways
- A minimal Express server needs five elements: import, create app, define route, listen.
express()returns an app instance — a function with.get(),.post(),.use(),.listen().- Never hardcode the port — use
process.env.PORT || 3000. - Separate
app.js(configuration) fromserver.js(startup) for testability. res.json()for APIs,res.send()for HTML,res.status()for chaining status codes.- nodemon auto-restarts your server on file changes — essential for development.
- Always add a 404 catch-all and a global error handler (4-parameter middleware).
Explain-It Challenge
Explain without notes:
- Walk through what happens when you run
node server.jswith a basic Express app — from process start to "server running" log. - Why should you separate
app.jsfromserver.js? - What is the difference between
res.send({ data: 1 })andres.json({ data: 1 })? - Why does the 404 catch-all middleware need to be placed after all route definitions?
Navigation: <- 3.4.a — What is Express.js? | 3.4.c — Returning Responses ->