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

LineWhat 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 = 3000Stores 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}`);
});
ContextHow PORT is set
Local developmentFalls back to 3000 (or whatever default you choose)
Heroku / Railway / RenderPlatform injects process.env.PORT automatically
DockerSet via -e PORT=8080 or docker-compose.yml
.env fileLoad 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()

MethodSets Content-TypeUse case
res.send(string)text/htmlSending HTML or plain text
res.send(object)application/jsonSending JSON (auto-detected)
res.send(buffer)application/octet-streamSending binary data
res.json(object)application/jsonAlways 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

CodeMeaningWhen to use
200OKSuccessful GET, PUT, PATCH
201CreatedSuccessful POST that creates a resource
204No ContentSuccessful DELETE (no body returned)
400Bad RequestInvalid input / validation error
401UnauthorizedMissing or invalid authentication
403ForbiddenAuthenticated but no permission
404Not FoundResource does not exist
500Internal Server ErrorUnhandled 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

  1. Return after sending error responses — prevents "headers already sent" errors
  2. 404 catch-all goes after all routes (Express tries routes top-to-bottom)
  3. 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

  1. Runs your script with Node
  2. Watches for file changes in your project directory
  3. Automatically restarts the server when .js, .json, or .mjs files change
  4. 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
}
OptionWhat it does
watchDirectories to monitor
extFile extensions that trigger restart
ignoreDirectories/files to skip
delayWait 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

  1. A minimal Express server needs five elements: import, create app, define route, listen.
  2. express() returns an app instance — a function with .get(), .post(), .use(), .listen().
  3. Never hardcode the port — use process.env.PORT || 3000.
  4. Separate app.js (configuration) from server.js (startup) for testability.
  5. res.json() for APIs, res.send() for HTML, res.status() for chaining status codes.
  6. nodemon auto-restarts your server on file changes — essential for development.
  7. Always add a 404 catch-all and a global error handler (4-parameter middleware).

Explain-It Challenge

Explain without notes:

  1. Walk through what happens when you run node server.js with a basic Express app — from process start to "server running" log.
  2. Why should you separate app.js from server.js?
  3. What is the difference between res.send({ data: 1 }) and res.json({ data: 1 })?
  4. 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 ->