Backend

Build a REST API With Node.js and Express: From Zero to Production

A complete, hands-on tutorial for building production-ready REST APIs with Node.js and Express — routing, middleware, database, JWT auth, validation, and deployment.

June 3, 202613 min read
Share
Advertisement (not configured)

Introduction

If you can build a REST API with Node.js and Express, you can build the backend for almost any web or mobile app on the planet. Express has been the default Node.js framework for over a decade — battle-tested, minimal, and used by Netflix, Uber, and thousands of startups still launching in 2026.

This guide builds a real, working API for a simple "Notes" app, step by step:

  1. Project setup and your first server
  2. RESTful routing for CRUD operations
  3. Middleware — the secret weapon of Express
  4. Connecting a database (MongoDB)
  5. JWT authentication
  6. Input validation and error handling
  7. Production-ready deployment

By the end, you'll have a complete API you can extend into anything.

Setup: Your First Express Server

Create a new project:

mkdir notes-api && cd notes-api
npm init -y
npm install express
npm install -D nodemon

Update package.json to add a dev script:

{
  "scripts": {
    "dev": "nodemon server.js",
    "start": "node server.js"
  },
  "type": "module"
}

Setting "type": "module" lets you use modern import syntax instead of require.

Create server.js:

import express from 'express';

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

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

app.get('/', (req, res) => {
  res.json({ message: 'API is running' });
});

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

Run it:

npm run dev

Open http://localhost:3000 — you should see {"message":"API is running"}. That's it. You have a working Node.js server in 10 lines of code.

1. RESTful Routing: The Five Endpoints That Cover Everything

A REST API for any resource follows the same pattern. For our "notes":

Method URL What it does
GET /api/notes List all notes
GET /api/notes/:id Get one note
POST /api/notes Create a new note
PUT /api/notes/:id Update an existing note
DELETE /api/notes/:id Delete a note

Master these five and you understand REST. Let's implement them — first with an in-memory array, then we'll add a database:

import express from 'express';

const app = express();
app.use(express.json());

let notes = [
  { id: 1, title: 'First note', body: 'Hello world' },
];
let nextId = 2;

// GET all
app.get('/api/notes', (req, res) => {
  res.json(notes);
});

// GET one
app.get('/api/notes/:id', (req, res) => {
  const note = notes.find(n => n.id === parseInt(req.params.id));
  if (!note) return res.status(404).json({ error: 'Not found' });
  res.json(note);
});

// CREATE
app.post('/api/notes', (req, res) => {
  const { title, body } = req.body;
  if (!title) return res.status(400).json({ error: 'Title required' });

  const note = { id: nextId++, title, body: body || '' };
  notes.push(note);
  res.status(201).json(note);
});

// UPDATE
app.put('/api/notes/:id', (req, res) => {
  const id = parseInt(req.params.id);
  const note = notes.find(n => n.id === id);
  if (!note) return res.status(404).json({ error: 'Not found' });

  note.title = req.body.title ?? note.title;
  note.body = req.body.body ?? note.body;
  res.json(note);
});

// DELETE
app.delete('/api/notes/:id', (req, res) => {
  const id = parseInt(req.params.id);
  notes = notes.filter(n => n.id !== id);
  res.status(204).end();
});

app.listen(3000, () => console.log('Running on 3000'));

Test it from your terminal:

# Create a note
curl -X POST http://localhost:3000/api/notes \
  -H "Content-Type: application/json" \
  -d '{"title":"My note","body":"Hello"}'

# List all
curl http://localhost:3000/api/notes

# Get one
curl http://localhost:3000/api/notes/2

# Delete
curl -X DELETE http://localhost:3000/api/notes/2

That's a fully working CRUD API. Now let's make it production-quality.

2. Middleware: The Secret Weapon of Express

Middleware is just a function that runs between the request and the response. You use middleware for logging, authentication, parsing, error handling — anything that should happen to every request.

import express from 'express';

const app = express();

// Built-in middleware — runs on every request
app.use(express.json());

// Custom middleware — log every request
app.use((req, res, next) => {
  console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
  next();  // pass control to the next middleware/route
});

// Middleware only for specific routes
const requireAuth = (req, res, next) => {
  const token = req.headers.authorization;
  if (!token) return res.status(401).json({ error: 'No token' });
  // verify token here...
  next();
};

app.get('/api/protected', requireAuth, (req, res) => {
  res.json({ data: 'secret stuff' });
});

The next() call is critical — without it, the request hangs forever.

The five middlewares you'll use in every project:

import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import compression from 'compression';

app.use(helmet());                  // sets secure HTTP headers
app.use(cors());                    // allows requests from other origins
app.use(morgan('dev'));             // logs requests nicely
app.use(compression());             // gzips responses
app.use(rateLimit({                 // prevents abuse
  windowMs: 15 * 60 * 1000,
  max: 100,
}));

Install them all:

npm install cors helmet morgan express-rate-limit compression

3. Connecting MongoDB

In-memory data dies when the server restarts. Real APIs need a database. MongoDB pairs naturally with Node.js — use Mongoose for clean, schema-based access.

npm install mongoose

Sign up for a free MongoDB Atlas account at mongodb.com/atlas, create a cluster, and copy the connection string.

db.js:

import mongoose from 'mongoose';

export async function connectDB() {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('✅ MongoDB connected');
  } catch (err) {
    console.error('❌ MongoDB connection failed:', err.message);
    process.exit(1);
  }
}

models/Note.js:

import mongoose from 'mongoose';

const noteSchema = new mongoose.Schema(
  {
    title: { type: String, required: true, trim: true },
    body: { type: String, default: '' },
    userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  },
  { timestamps: true }  // adds createdAt + updatedAt automatically
);

export const Note = mongoose.model('Note', noteSchema);

routes/notes.js:

import { Router } from 'express';
import { Note } from '../models/Note.js';

const router = Router();

router.get('/', async (req, res, next) => {
  try {
    const notes = await Note.find().sort('-createdAt');
    res.json(notes);
  } catch (err) {
    next(err);
  }
});

router.get('/:id', async (req, res, next) => {
  try {
    const note = await Note.findById(req.params.id);
    if (!note) return res.status(404).json({ error: 'Not found' });
    res.json(note);
  } catch (err) {
    next(err);
  }
});

router.post('/', async (req, res, next) => {
  try {
    const note = await Note.create(req.body);
    res.status(201).json(note);
  } catch (err) {
    next(err);
  }
});

router.put('/:id', async (req, res, next) => {
  try {
    const note = await Note.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );
    if (!note) return res.status(404).json({ error: 'Not found' });
    res.json(note);
  } catch (err) {
    next(err);
  }
});

router.delete('/:id', async (req, res, next) => {
  try {
    await Note.findByIdAndDelete(req.params.id);
    res.status(204).end();
  } catch (err) {
    next(err);
  }
});

export default router;

server.js (refactored):

import express from 'express';
import 'dotenv/config';
import { connectDB } from './db.js';
import notesRouter from './routes/notes.js';

const app = express();
app.use(express.json());
app.use('/api/notes', notesRouter);

await connectDB();
app.listen(process.env.PORT || 3000);

Create a .env file:

MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net/notesdb
PORT=3000
JWT_SECRET=your-super-secret-key-change-me

Install dotenv:

npm install dotenv

Now your data persists. Restart the server, and your notes are still there.

4. JWT Authentication

Every real API needs auth. JWT (JSON Web Tokens) is the standard.

npm install bcryptjs jsonwebtoken

models/User.js:

import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';

const userSchema = new mongoose.Schema({
  email: { type: String, unique: true, required: true, lowercase: true },
  password: { type: String, required: true, minlength: 8 },
}, { timestamps: true });

userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 12);
  next();
});

userSchema.methods.comparePassword = function (plain) {
  return bcrypt.compare(plain, this.password);
};

export const User = mongoose.model('User', userSchema);

routes/auth.js:

import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { User } from '../models/User.js';

const router = Router();

function signToken(userId) {
  return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '7d' });
}

router.post('/register', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await User.create({ email, password });
    res.status(201).json({ token: signToken(user._id) });
  } catch (err) {
    if (err.code === 11000) {
      return res.status(409).json({ error: 'Email already registered' });
    }
    next(err);
  }
});

router.post('/login', async (req, res, next) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email });
    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    res.json({ token: signToken(user._id) });
  } catch (err) {
    next(err);
  }
});

export default router;

middleware/auth.js:

import jwt from 'jsonwebtoken';

export function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' });
  }

  try {
    const token = header.split(' ')[1];
    const { userId } = jwt.verify(token, process.env.JWT_SECRET);
    req.userId = userId;
    next();
  } catch {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Wire it up in server.js:

import authRouter from './routes/auth.js';
import { requireAuth } from './middleware/auth.js';

app.use('/api/auth', authRouter);
app.use('/api/notes', requireAuth, notesRouter);  // protect notes

Now users register, log in, and get a token. The token must be sent in the Authorization: Bearer <token> header for any protected request.

Test it:

# Register
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"a@b.com","password":"password123"}'
# → {"token":"eyJhbGc..."}

# Use the token
curl http://localhost:3000/api/notes \
  -H "Authorization: Bearer eyJhbGc..."

5. Validation With Zod

Never trust user input. Use Zod to validate every request body:

npm install zod

middleware/validate.js:

export const validate = (schema) => (req, res, next) => {
  const result = schema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({
      error: 'Validation failed',
      details: result.error.flatten().fieldErrors,
    });
  }
  req.body = result.data;
  next();
};

Use it in a route:

import { z } from 'zod';
import { validate } from '../middleware/validate.js';

const createNoteSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().max(10_000).optional(),
});

router.post('/', validate(createNoteSchema), async (req, res, next) => {
  const note = await Note.create({ ...req.body, userId: req.userId });
  res.status(201).json(note);
});

Bad input gets rejected with a clear error before your route runs.

6. Centralized Error Handling

A single error handler at the bottom of server.js catches everything:

// 404 — no route matched
app.use((req, res) => {
  res.status(404).json({ error: 'Route not found' });
});

// Global error handler — must have 4 arguments
app.use((err, req, res, next) => {
  console.error(err);

  if (err.name === 'ValidationError') {
    return res.status(400).json({ error: err.message });
  }
  if (err.name === 'CastError') {
    return res.status(400).json({ error: 'Invalid ID' });
  }

  res.status(err.status || 500).json({
    error: process.env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message,
  });
});

The 4-argument signature (err, req, res, next) is how Express knows it's an error handler.

7. Production Checklist

Before you deploy, run through this:

Concern Action
Secrets Use .env + dotenv. Never commit. Add .env to .gitignore
HTTPS Use a reverse proxy (Nginx) or platform like Vercel/Render
Logging morgan for dev, structured logs (Pino) for production
Error monitoring Sentry or Logtail
Rate limiting express-rate-limit on auth routes especially
CORS Restrict origins with cors({ origin: 'https://yourdomain.com' })
Headers helmet() handles 90% of security headers
Validation Zod on every body, query, and param
Indexes Add index: true to fields you query often
Tests Use Vitest or Jest + Supertest

Deploying in 5 minutes

The fastest way to deploy a Node.js API in 2026:

  1. Push your code to GitHub
  2. Sign up at render.com (free tier)
  3. New → Web Service → connect GitHub repo
  4. Build command: npm install
  5. Start command: node server.js
  6. Add your environment variables (MONGODB_URI, JWT_SECRET)
  7. Deploy

Your API is live at https://your-app.onrender.com in about 3 minutes.

Alternatives: Railway, Fly.io, DigitalOcean App Platform. All similar.

The Full Project Structure

notes-api/
├── server.js               ← entry point
├── db.js                   ← MongoDB connection
├── .env                    ← secrets (gitignored)
├── package.json
├── models/
│   ├── Note.js
│   └── User.js
├── routes/
│   ├── auth.js
│   └── notes.js
├── middleware/
│   ├── auth.js
│   └── validate.js
└── README.md

Small, organized, and easy to extend. This structure scales to APIs with 50+ endpoints.

What to Build Next

You now have a real REST API. Extend it into:

1. Add file uploads with multer (images on notes)
2. Add full-text search with MongoDB text indexes
3. Add WebSocket support with Socket.io for real-time updates
4. Add Redis caching for hot endpoints
5. Add OpenAPI/Swagger docs at /docs
6. Add email verification with Nodemailer
7. Build a frontend (Next.js / React) that consumes this API

Final Thought

The architecture in this tutorial — Express + Mongoose + JWT + Zod — is exactly what most production Node.js APIs look like in 2026. You don't need NestJS, Fastify, or any framework-of-the-month to build something real. You need a clean structure, validation on every input, auth on every protected route, and a database that persists.

Build the Notes API end-to-end. Then build the same thing for a different resource (todos, books, customers). After three of these, REST API design becomes muscle memory — and you've leveled up into a backend developer who can actually ship.

Advertisement (not configured)

Written by

Raretechsol

International software company specializing in Python and JavaScript. Passionate about automation, AI, and building practical web applications.