DEV Community

Orbit Websites
Orbit Websites

Posted on

How to Build a REST API with Node.js and Express in 2026 – The Ultimate Guide

How to Build a REST API with Node.js and Express in 2026 – The Ultimate Guide

In 2026, Node.js and Express are still the go-to combo for building fast, scalable REST APIs. While newer frameworks exist, Express remains battle-tested, lightweight, and flexible enough for most use cases. If you're shipping APIs today — whether for a startup MVP or a microservice in a larger system — knowing how to do it right with Express is still a core skill.

Let’s cut the fluff and build a real, production-ready REST API from the ground up.


1. Set Up Your Project (Properly)

Start with a clean slate and modern tooling.

mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev nodemon eslint
Enter fullscreen mode Exit fullscreen mode

Use nodemon for development so you don’t restart the server every time. Add scripts to package.json:

"scripts": {
  "start": "node server.js",
  "dev": "nodemon server.js"
}
Enter fullscreen mode Exit fullscreen mode

Initialize ESLint with npx eslint --init — pick Airbnb or Standard, doesn’t matter, just pick one and stick to it.


2. Write Your First Server (Minimal but Clean)

Create server.js:

import express from 'express';

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

// Built-in middleware
app.use(express.json());

app.get('/', (req, res) => {
  res.json({ message: 'Welcome to the API' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});
Enter fullscreen mode Exit fullscreen mode

Yes, use ES modules ("type": "module" in package.json) — it’s 2026, and you should be using import/export.


3. Structure Your App Like a Pro

No one wants a 500-line server.js. Use a simple, scalable structure:

src/
├── routes/
├── controllers/
├── models/
├── middleware/
└── utils/
Enter fullscreen mode Exit fullscreen mode

Create a route for users:

// routes/userRoutes.js
import { Router } from 'express';
import { getUsers, createUser } from '../controllers/userController.js';

const router = Router();

router.get('/', getUsers);
router.post('/', createUser);

export default router;
Enter fullscreen mode Exit fullscreen mode

Then mount it in server.js:

import userRoutes from './src/routes/userRoutes.js';
app.use('/api/users', userRoutes);
Enter fullscreen mode Exit fullscreen mode

This keeps things modular and testable.


4. Use Controllers, Not Logic in Routes

Keep routes thin. Business logic goes in controllers.

// controllers/userController.js
let users = []; // Replace with DB later

export const getUsers = (req, res) => {
  res.json(users);
};

export const createUser = (req, res) => {
  const { name, email } = req.body;

  if (!name || !email) {
    return res.status(400).json({ error: 'Name and email required' });
  }

  const user = { id: users.length + 1, name, email };
  users.push(user);

  res.status(201).json(user);
};
Enter fullscreen mode Exit fullscreen mode

This is mock data — but the pattern stays the same when you plug in a real database.


5. Add Error Handling Middleware

Don’t let unhandled errors crash your server or leak stack traces.

Create middleware/errorHandler.js:

export const errorHandler = (err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({
    message: 'Something went wrong!',
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
};
Enter fullscreen mode Exit fullscreen mode

Use it at the end of your middleware chain in server.js:

app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

Also, handle 404s:

app.use('*', (req, res) => {
  res.status(404).json({ error: 'Route not found' });
});
Enter fullscreen mode Exit fullscreen mode

Order matters — error handlers go last.


6. Validate Input Early

Use a lightweight validator. I recommend Zod — it’s type-safe and works great with TypeScript.

npm install zod
Enter fullscreen mode Exit fullscreen mode
// utils/validate.js
import { z } from 'zod';

const userSchema = z.object({
  name: z.string().min(1),
  email: z.string().email(),
});

export const validate = (schema) => (req, res, next) => {
  try {
    schema.parse(req.body);
    next();
  } catch (err) {
    return res.status(400).json({ error: err.errors });
  }
};
Enter fullscreen mode Exit fullscreen mode

Now use it in your route:

router.post('/', validate(userSchema), createUser);
Enter fullscreen mode Exit fullscreen mode

Validation at the edge prevents garbage from entering your system.


7. Use Environment Variables

Never hardcode config. Use .env:

npm install dotenv
Enter fullscreen mode Exit fullscreen mode
// server.js
import 'dotenv/config';
Enter fullscreen mode Exit fullscreen mode

Create .env:

PORT=5000
NODE_ENV=development
Enter fullscreen mode Exit fullscreen mode

Now process.env.PORT works everywhere.


8. Connect to a Database (Prisma + PostgreSQL)

For 2026, Prisma is still one of the best ORMs — it’s intuitive and type-safe.

npm install prisma pg
npx prisma init
Enter fullscreen mode Exit fullscreen mode

Set up prisma/schema.prisma:

model User {
  id    Int     @id @default(autoincrement())
  name  String
  email String  @unique
}
Enter fullscreen mode Exit fullscreen mode

Run npx prisma migrate dev --name init to create the table.

Now update your controller:

// controllers/userController.js
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();

export const getUsers = async (req, res) => {
  const users = await prisma.user.findMany();
  res.json(users);
};

export const createUser = async (req, res) => {
  const { name, email } = req.body;
  try {
    const user = await prisma.user.create({ data: { name, email } });
    res.status(201).json(user);
  } catch (err) {
    res.status(400).json({ error: 'Email already exists' });
  }
};
Enter fullscreen mode Exit fullscreen mode

Don’t forget to handle DB errors.


9. Add Logging (Don’t Guess, Know)

Use Pino — fast, JSON-based, and production-ready.

npm install pino
Enter fullscreen mode Exit fullscreen mode
import pino from 'pino';
const logger = pino();

// In your routes
logger.info('User created', { userId: user.id });
Enter fullscreen mode Exit fullscreen mode

Pipe logs to your observability stack (Datadog, Grafana, etc.). Local dev? Just use pino-pretty.


10. Secure Your API (Basics)

Even a simple API needs basic security:

npm install helmet cors
Enter fullscreen mode Exit fullscreen mode
import helmet from 'helmet';
import cors from 'cors';

app.use(helmet()); // sets secure headers
app.use(cors());   // allow frontend origins
Enter fullscreen mode Exit fullscreen mode

Also rate-limit public endpoints:

npm install express-rate-limit
Enter fullscreen mode Exit fullscreen mode
import rateLimit from 'express-rate-limit';

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP
});

app.use('/api/', limiter);
Enter fullscreen mode Exit fullscreen mode

11. Test the Damn Thing

Use Jest and Supertest:

npm install --save-dev jest supertest
Enter fullscreen mode Exit fullscreen mode
// test/user.test.js
import request from 'supertest';
import app from '../server.js';

describe('GET /api/users', () => {
  it('returns 200 and users', async () => {
    const res = await request(app).get('/api/users');
    expect(res.statusCode).toBe(200);
    expect(Array.isArray(res.body)).toBe(true);
  });
});
Enter fullscreen mode Exit fullscreen mode

Run with npx jest. Write tests. Your future self will thank you.


Conclusion

Building a REST API in 2026 with Node.js and Express isn’t about magic — it’s about doing the boring stuff right:

  • Structure your code
  • Validate input
  • Handle errors
  • Use a real database
  • Log, secure, and test

Express isn’t flashy, but it’s reliable. And when you combine it with modern tooling (Prisma, Zod, Pino), you’ve got a stack that scales and survives real-world use.

Don’t overcomplicate it. Build, ship, iterate.

Now go write some code.

Top comments (0)