Step-by-step
-
1
Initialise the project
Create a new directory, initialise a
package.json, and install Express. Installnodemonas a dev dependency for automatic restarts during development. Also installdotenvfor environment variable management from day one — you will need it before long.bashmkdir my-api && cd my-api npm init -y npm install express dotenv npm install --save-dev nodemon # Add a start script and a dev script to package.json # We will do that in the next step -
2
Configure package.json scripts
Add the
startanddevscripts. Thedevscript uses nodemon, which watches for file changes and restarts the server automatically. Node.js 18+ also ships a built-in--watchflag — use whichever you prefer.json{ "name": "my-api", "version": "1.0.0", "main": "src/app.js", "scripts": { "start": "node src/app.js", "dev": "nodemon src/app.js" // Modern alternative — no nodemon needed: // "dev": "node --watch src/app.js" }, "dependencies": { "dotenv": "^16.0.0", "express": "^4.18.0" }, "devDependencies": { "nodemon": "^3.0.0" } } -
3
Create the Express entry point
Create
src/app.js. This file boots Express, mountsexpress.json()(body parser for JSON requests), attaches your routers, and adds a 404 fallback and a central error handler. Keep this file thin — actual logic lives in routes and controllers.javascript// src/app.js require("dotenv").config() const express = require("express") const usersRouter = require("./routes/users") const app = express() const PORT = process.env.PORT || 3000 // Middleware app.use(express.json()) // Routes app.use("/api/users", usersRouter) // 404 fallback app.use((req, res) => { res.status(404).json({ error: "Not found" }) }) // Central error handler app.use((err, req, res, next) => { console.error(err.stack) res.status(500).json({ error: "Internal server error" }) }) app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`) }) module.exports = app -
4
Create the users router
Create
src/routes/users.js. Routers group related endpoints. This file maps HTTP methods and paths to controller functions — it contains no business logic itself. Usingexpress.Router()keeps each resource's routes in its own file.javascript// src/routes/users.js const express = require("express") const router = express.Router() const usersController = require("../controllers/usersController") router.get("/", usersController.getAll) router.get("/:id", usersController.getOne) router.post("/", usersController.create) router.put("/:id", usersController.update) router.delete("/:id", usersController.remove) module.exports = router -
5
Write the controller with in-memory storage
Create
src/controllers/usersController.js. This is where the actual request handling lives. The in-memory array is intentionally naive — in a real project replace it with database calls. Controllers should be thin too: validate input (or delegate to a validator), call the data layer, return the response.javascript// src/controllers/usersController.js let users = [ { id: 1, name: "Alice", email: "alice@example.com" }, { id: 2, name: "Bob", email: "bob@example.com" }, ] let nextId = 3 exports.getAll = (req, res) => { res.json(users) } exports.getOne = (req, res) => { const user = users.find((u) => u.id === Number(req.params.id)) if (!user) return res.status(404).json({ error: "User not found" }) res.json(user) } exports.create = (req, res) => { const { name, email } = req.body if (!name || !email) { return res.status(400).json({ error: "name and email are required" }) } const user = { id: nextId++, name, email } users.push(user) res.status(201).json(user) } exports.update = (req, res) => { const index = users.findIndex((u) => u.id === Number(req.params.id)) if (index === -1) return res.status(404).json({ error: "User not found" }) users[index] = { ...users[index], ...req.body, id: users[index].id } res.json(users[index]) } exports.remove = (req, res) => { const index = users.findIndex((u) => u.id === Number(req.params.id)) if (index === -1) return res.status(404).json({ error: "User not found" }) users.splice(index, 1) res.status(204).send() } -
6
Test every endpoint
Start the dev server and smoke-test all five operations. Use
curlor import these into Postman.bashnpm run dev # List all users curl http://localhost:3000/api/users # Get one user curl http://localhost:3000/api/users/1 # Create a user curl -X POST http://localhost:3000/api/users \ -H "Content-Type: application/json" \ -d '{"name":"Carol","email":"carol@example.com"}' # Update a user curl -X PUT http://localhost:3000/api/users/3 \ -H "Content-Type: application/json" \ -d '{"name":"Caroline"}' # Delete a user curl -X DELETE http://localhost:3000/api/users/3 -
7
Use proper HTTP status codes
Consistent status codes are what separates a professional API from a toy. Follow these conventions in every handler:
- 200 OK — successful GET, PUT, PATCH
- 201 Created — successful POST that created a resource
- 204 No Content — successful DELETE (no body)
- 400 Bad Request — malformed input, missing required fields
- 404 Not Found — resource does not exist
- 422 Unprocessable Entity — input is valid JSON but fails business validation
- 500 Internal Server Error — unhandled exception (should rarely reach the client)
-
8
Add project structure for scale
The current flat layout works for one or two resources. As the project grows, organise it like this. The database layer is separated from the controller so you can swap storage without touching route logic.
bashsrc/ ├── app.js # Entry point — boot Express, mount routers ├── routes/ │ ├── users.js # Route definitions for /api/users │ └── posts.js # Route definitions for /api/posts ├── controllers/ │ ├── usersController.js │ └── postsController.js ├── models/ # Database access layer (queries, ORM calls) │ ├── User.js │ └── Post.js ├── middleware/ │ ├── auth.js # JWT verification │ └── validate.js # Input validation wrapper └── config/ └── db.js # Database connection setup
Tips & gotchas
- Mount `express.json()` before your routes, not after — middleware runs in order. Forgetting this is why `req.body` is sometimes `undefined`.
- Always handle the case where `req.params.id` is not a valid number before doing a database lookup — a non-numeric ID can cause unexpected query errors.
- Use `express.Router()` for every resource. Grouping routes in the same `app.js` works for demos but becomes unmanageable past three or four endpoints.
- When replacing in-memory storage with a database, keep the controller signatures the same — only the model functions change. This makes the refactor mechanical.
Wrapping up
Express gives you a routing layer and a middleware pipeline. Everything you build on top — controllers, models, validation — is plain JavaScript. That simplicity is a feature: you can read the entire stack without framework magic getting in the way. Once you are comfortable with this structure, adding a database, authentication, and input validation is a matter of dropping in the right libraries in the right layers.