A Step-by-Step Guide to Setting Up a Node.js API with Express

You’ve probably heard the buzz about “headless” architectures, micro‑services, and “serverless” functions. The common thread? A solid, lightweight API that talks JSON. If you’re a front‑end developer who’s finally ready to dip a toe into the backend, Express is the friendliest bridge you’ll find. In this post I’ll walk you through creating a minimal Node.js API from scratch, explain why each step matters, and sprinkle in a few of the pitfalls I’ve tripped over in my own projects.

Why Express Still Matters in 2024

When I first started building APIs, I was juggling a dozen different frameworks, each promising “the next big thing.” Fastify, Koa, Hapi – all great, but each added its own learning curve. Express, by contrast, stays true to the Unix philosophy: do one thing well and let you compose the rest. It’s tiny, it’s battle‑tested, and it integrates seamlessly with the tools we already love (TypeScript, Prisma, Docker, you name it). That stability is why many production services still run on Express, even as serverless platforms rise.

Prerequisites

Before we dive in, make sure you have:

  • Node.js (v18 or later) installed – you can verify with node -v.
  • npm or yarn – the package manager you’re comfortable with.
  • A code editor – VS Code works great for me; its integrated terminal saves a lot of clicks.
  • Basic familiarity with JavaScript – if you’re comfortable with async/await, you’re good to go.

1. Scaffold the Project

Open your terminal and create a new folder:

mkdir my-express-api
cd my-express-api
npm init -y

The npm init -y command generates a package.json with default values. Feel free to edit the file later – give your project a meaningful name, version, and description.

Install Core Dependencies

npm install express
npm install --save-dev nodemon
  • express – the web framework that will handle routing and middleware.
  • nodemon – a development helper that restarts the server automatically when you change a file.

Add a start script to package.json:

"scripts": {
  "start": "node index.js",
  "dev": "nodemon index.js"
}

Now you can run npm run dev to launch the server in watch mode.

2. Create the Entry Point

Create a file called index.js at the root of the project:

const express = require('express');
const app = express();
const PORT = process.env.PORT || 3000;

// Middleware to parse JSON bodies
app.use(express.json());

// Simple health check route
app.get('/health', (req, res) => {
  res.json({ status: 'OK', timestamp: Date.now() });
});

app.listen(PORT, () => {
  console.log(`🚀 Server listening on http://localhost:${PORT}`);
});

A few things to note:

  • express.json() is built‑in middleware that parses incoming JSON payloads, so you don’t have to manually JSON.parse the request body.
  • The /health endpoint is a tiny but useful convention. Load balancers and monitoring tools love a quick “are you alive?” check.

Run npm run dev and visit http://localhost:3000/health – you should see a JSON response.

3. Organize Routes and Controllers

As your API grows, you’ll want to separate concerns. Let’s build a tiny “todos” resource.

Folder Structure

my-express-api/
│
├─ routes/
│   └─ todos.js
├─ controllers/
│   └─ todosController.js
├─ models/
│   └─ todo.js
└─ index.js

Define a Simple Model

For this guide we’ll keep data in memory. Create models/todo.js:

let todos = [];
let idCounter = 1;

module.exports = {
  getAll: () => todos,
  getById: (id) => todos.find(t => t.id === id),
  create: (payload) => {
    const newTodo = { id: idCounter++, ...payload };
    todos.push(newTodo);
    return newTodo;
  },
  update: (id, payload) => {
    const index = todos.findIndex(t => t.id === id);
    if (index === -1) return null;
    todos[index] = { ...todos[index], ...payload };
    return todos[index];
  },
  remove: (id) => {
    const index = todos.findIndex(t => t.id === id);
    if (index === -1) return false;
    todos.splice(index, 1);
    return true;
  }
};

This module mimics a tiny data layer. In a real project you’d swap it out for a database client (Prisma, Mongoose, etc.).

Write Controllers

Create controllers/todosController.js:

const Todo = require('../models/todo');

exports.list = (req, res) => {
  res.json(Todo.getAll());
};

exports.get = (req, res) => {
  const todo = Todo.getById(parseInt(req.params.id));
  if (!todo) return res.status(404).json({ error: 'Not found' });
  res.json(todo);
};

exports.create = (req, res) => {
  const { title, completed = false } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  const newTodo = Todo.create({ title, completed });
  res.status(201).json(newTodo);
};

exports.update = (req, res) => {
  const updates = req.body;
  const updated = Todo.update(parseInt(req.params.id), updates);
  if (!updated) return res.status(404).json({ error: 'Not found' });
  res.json(updated);
};

exports.remove = (req, res) => {
  const success = Todo.remove(parseInt(req.params.id));
  if (!success) return res.status(404).json({ error: 'Not found' });
  res.status(204).send();
};

Each function receives the Express req and res objects, performs a simple operation, and sends back JSON. Notice the use of HTTP status codes: 201 for created, 204 for no content after deletion, and 400/404 for client errors. These conventions help API consumers handle responses predictably.

Wire Up the Router

Create routes/todos.js:

const express = require('express');
const router = express.Router();
const controller = require('../controllers/todosController');

router.get('/', controller.list);
router.get('/:id', controller.get);
router.post('/', controller.create);
router.put('/:id', controller.update);
router.delete('/:id', controller.remove);

module.exports = router;

Now modify index.js to mount this router:

// ... previous code ...

const todosRouter = require('./routes/todos');
app.use('/api/todos', todosRouter);

// ... rest unchanged ...

Restart the server (npm run dev) and test the endpoints with a tool like Postman or curl.

4. Add Basic Error Handling Middleware

Express lets you define a “catch‑all” error handler. Place it after all routes:

// Global error handler
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

If any controller throws an exception, this middleware will catch it and return a generic 500 response. In production you’d want more sophisticated logging (Winston, Pino) and perhaps different messages for development vs. production.

5. Enable CORS for Front‑End Consumption

Most browsers block cross‑origin requests unless the server explicitly allows them. Install the cors package:

npm install cors

Then add it near the top of index.js:

const cors = require('cors');
app.use(cors()); // defaults to allowing all origins

For tighter security you can pass an options object, e.g., { origin: 'https://myfrontend.com' }.

6. Environment Variables and Configuration

Hard‑coding values like the port works for demos, but real deployments need flexibility. Create a .env file (add it to .gitignore later) and install dotenv:

npm install dotenv

At the very top of index.js:

require('dotenv').config();
const PORT = process.env.PORT || 3000;

Now you can set PORT=8080 in .env without touching code.

7. Dockerize the API (Optional but Handy)

If you plan to ship this service to a cloud provider, a Docker container gives you consistency across environments.

Create a Dockerfile:

# Use official Node LTS image
FROM node:18-alpine

# Create app directory
WORKDIR /app

# Install dependencies
COPY package*.json ./
RUN npm ci --only=production

# Copy source code
COPY . .

# Expose the port the app runs on
EXPOSE 3000

# Start the app
CMD ["node", "index.js"]

Build and run:

docker build -t my-express-api .
docker run -p 3000:3000 my-express-api

Your API is now containerized and ready for Kubernetes, ECS, or any host that runs Docker.

8. Testing the Endpoints Quickly

Even a handful of unit tests can catch regressions early. Install a lightweight testing stack:

npm install --save-dev jest supertest

Add a test file tests/todos.test.js:

const request = require('supertest');
const app = require('../index'); // assuming you export the Express app

describe('GET /api/todos', () => {
  it('should return an empty array initially', async () => {
    const res = await request(app).get('/api/todos');
    expect(res.statusCode).toBe(200);
    expect(res.body).toEqual([]);
  });
});

Update package.json:

"scripts": {
  "test": "jest"
}

Run npm test. If the test passes, you have a baseline you can extend as you add features.

9. Deploying to a Free Tier

For a quick demo, I love using Render or Railway – they spin up a Node environment with a single git push. The steps are basically:

  1. Push your repo to GitHub.
  2. Connect the repo to the Render service.
  3. Set the start command to npm start.
  4. Add any environment variables (like PORT).

Within minutes you’ll have a public URL like https://my-express-api.onrender.com/api/todos.

Wrap‑Up Thoughts

Building a Node.js API with Express is less about memorizing a mountain of configuration and more about understanding the flow: request → middleware → controller → model → response. Once you internalize that pipeline, you can swap out pieces (replace the in‑memory model with a real database, add authentication middleware, or move to TypeScript) without rewriting the whole thing.

I’ve kept this guide deliberately simple; the real world throws in authentication, rate limiting, validation libraries, and async error handling. But the skeleton you’ve just built is a solid foundation. Next time you’re staring at a blank repo, remember: a few lines of Express code can turn that blank canvas into a fully functional API ready for your next React or Vue front‑end.

Reactions