DEV Community

Cover image for Building REST APIs with Node.js and Express
APIVerve
APIVerve

Posted on • Originally published at blog.apiverve.com

Building REST APIs with Node.js and Express

I've built a lot of Express APIs. Some were good. Some taught me what not to do at 3am when production's on fire.

Here's the distilled version — how to build an API that won't embarrass you later.

The Skeleton

npm init -y
npm install express
Enter fullscreen mode Exit fullscreen mode
import express from 'express';

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

app.get('/health', (req, res) => res.json({ status: 'ok' }));

app.listen(3000, () => console.log('Running on 3000'));
Enter fullscreen mode Exit fullscreen mode

That's a working API. Everything else is just making it better.

Routes That Make Sense

REST has conventions. Follow them and your API becomes predictable.

// Resources are plural nouns
app.get('/users', getUsers);       // List
app.get('/users/:id', getUser);    // Read
app.post('/users', createUser);    // Create
app.put('/users/:id', updateUser); // Replace
app.delete('/users/:id', deleteUser); // Delete
Enter fullscreen mode Exit fullscreen mode

The HTTP method tells you what's happening. The URL tells you what you're working with. Don't get creative here.

Middleware for Everything

Middleware is code that runs before your route handler. Authentication, logging, validation — it all goes here.

// Auth middleware
function requireAuth(req, res, next) {
  const key = req.headers['x-api-key'];
  if (!key || !isValidKey(key)) {
    return res.status(401).json({ error: 'Invalid API key' });
  }
  req.user = getUserFromKey(key);
  next();
}

// Apply to routes that need it
app.get('/users', requireAuth, getUsers);
Enter fullscreen mode Exit fullscreen mode

next() continues to the next middleware or route. Without it, the request hangs forever.

Error Handling That Doesn't Leak

Never expose stack traces to users. Never.

// Async wrapper (catches promise rejections)
const wrap = fn => (req, res, next) => fn(req, res, next).catch(next);

app.get('/users/:id', wrap(async (req, res) => {
  const user = await db.users.findById(req.params.id);
  if (!user) throw new NotFoundError('User not found');
  res.json({ data: user });
}));

// Global error handler (must be last)
app.use((err, req, res, next) => {
  console.error(err); // Log the real error

  // Send safe response
  const status = err.statusCode || 500;
  const message = status === 500 ? 'Something went wrong' : err.message;
  res.status(status).json({ error: message });
});
Enter fullscreen mode Exit fullscreen mode

Log everything. Return nothing sensitive.

Response Format: Pick One, Stick With It

I use this structure for everything:

// Success
res.json({
  status: 'ok',
  data: { /* actual payload */ }
});

// Error
res.status(400).json({
  status: 'error',
  error: 'What went wrong'
});
Enter fullscreen mode Exit fullscreen mode

Consistent structure means clients can parse responses without checking the status code first.

Validation

Never trust input. Ever.

function validateUser(req, res, next) {
  const { email, name } = req.body;

  if (!email?.includes('@')) {
    return res.status(400).json({ error: 'Invalid email' });
  }
  if (!name || name.length < 2) {
    return res.status(400).json({ error: 'Name too short' });
  }

  next();
}

app.post('/users', validateUser, createUser);
Enter fullscreen mode Exit fullscreen mode

Validate early, fail fast. Don't let bad data reach your database.

Calling External APIs

Your API will probably call other APIs. Here's how to do it cleanly:

async function validateEmail(email) {
  const response = await fetch(
    `https://api.apiverve.com/v1/emailvalidator?email=${encodeURIComponent(email)}`,
    { headers: { 'x-api-key': process.env.APIVERVE_KEY } }
  );

  if (!response.ok) throw new Error('Validation service unavailable');

  return response.json();
}

// In your route
app.post('/register', wrap(async (req, res) => {
  const validation = await validateEmail(req.body.email);
  if (!validation.data.isValid) {
    return res.status(400).json({ error: 'Invalid email address' });
  }
  // Continue registration...
}));
Enter fullscreen mode Exit fullscreen mode

Abstract external calls into functions. Makes testing easier, keeps routes readable.

Environment Variables

Don't hardcode secrets. Don't commit them to git.

// At the top of your entry file
import 'dotenv/config';

const API_KEY = process.env.APIVERVE_KEY;
const PORT = process.env.PORT || 3000;
Enter fullscreen mode Exit fullscreen mode

.env goes in .gitignore. Always.

Rate Limiting

Protect yourself from abuse:

import rateLimit from 'express-rate-limit';

app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: 'Too many requests' }
}));
Enter fullscreen mode Exit fullscreen mode

Aggressive rate limiting on auth endpoints. Gentler limits elsewhere.

Status Codes That Mean Something

Code When to use
200 Success, returning data
201 Created something new
204 Success, nothing to return
400 Client sent bad data
401 Not authenticated
403 Authenticated but not allowed
404 Resource doesn't exist
429 Rate limited
500 You broke something

Get these right. Clients depend on them.

Project Structure

Once you have more than 10 routes, organize:

src/
  routes/
    users.js
    products.js
  middleware/
    auth.js
    validate.js
  services/
    email.js
  index.js
Enter fullscreen mode Exit fullscreen mode

Routes define endpoints. Middleware handles cross-cutting concerns. Services wrap external dependencies.

Before You Deploy

  • [ ] All secrets in environment variables
  • [ ] Error handler catches everything
  • [ ] Rate limiting configured
  • [ ] Validation on all inputs
  • [ ] Logging enabled
  • [ ] Health check endpoint works

That's the foundation. It's not fancy, but it works. The same patterns scale from side projects to production systems handling real traffic.

Need functionality without building it yourself? APIVerve has 500+ APIs you can drop into your Express app — same patterns, just call fetch instead of writing the logic.


Originally published at APIVerve Blog

Top comments (0)