Zuplo
Tutorial

Build and Secure an Express.js REST API with Zuplo

Nate TottenNate Totten
March 20, 2026
11 min read

Build a REST API with Express.js, generate an OpenAPI spec, and put it behind a production-grade gateway with rate limiting, API key auth, and a developer portal, no middleware required.

Express.js is the most popular Node.js framework for building APIs, and for good reason: minimal, flexible, and backed by an enormous ecosystem. But going from a working Express app to a production-ready API with authentication, rate limiting, and developer documentation is a bigger jump than most tutorials acknowledge.

In this tutorial, you’ll build a REST API with Express.js, generate an OpenAPI spec, and deploy it behind Zuplo as an API gateway. By the end, you’ll have rate limiting, API key authentication, and an auto-generated developer portal, without writing any middleware for those concerns.

Use this approach if you're:
  • Building a REST API with Node.js and Express
  • Looking for a straightforward deployment workflow
  • Need to add authentication and rate limiting without writing custom middleware
  • Want auto-generated API documentation from your OpenAPI spec

Prerequisites

  • Node.js 18+
  • A Railway account (or any Node.js hosting provider)
  • A Zuplo account (free)

Build a CRUD API with Express.js

Start by creating a new project directory and initializing it:

Terminalbash
mkdir express-bookstore-api && cd express-bookstore-api
npm init -y
npm install express

Create an index.js file with a basic CRUD API for managing books:

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

app.use(express.json());

// In-memory data store
let books = [];
let nextId = 1;

// List all books
app.get("/books", (req, res) => {
  res.json(books);
});

// Get a single book
app.get("/books/:id", (req, res) => {
  const book = books.find((b) => b.id === parseInt(req.params.id, 10));
  if (!book) {
    return res.status(404).json({ error: "Book not found" });
  }
  res.json(book);
});

// Create a book
app.post("/books", (req, res) => {
  const { title, author } = req.body;
  if (!title || !author) {
    return res.status(400).json({ error: "Title and author are required" });
  }
  const book = { id: nextId++, title, author };
  books.push(book);
  res.status(201).json(book);
});

// Update a book
app.put("/books/:id", (req, res) => {
  const book = books.find((b) => b.id === parseInt(req.params.id, 10));
  if (!book) {
    return res.status(404).json({ error: "Book not found" });
  }
  const { title, author } = req.body;
  if (title !== undefined) book.title = title;
  if (author !== undefined) book.author = author;
  res.json(book);
});

// Delete a book
app.delete("/books/:id", (req, res) => {
  const index = books.findIndex((b) => b.id === parseInt(req.params.id, 10));
  if (index === -1) {
    return res.status(404).json({ error: "Book not found" });
  }
  const [deleted] = books.splice(index, 1);
  res.json(deleted);
});

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

Run it locally to verify everything works:

Terminalbash
node index.js

Test it with curl:

Terminalbash
# Create a book
curl -X POST http://localhost:3000/books \
  -H "Content-Type: application/json" \
  -d '{"title": "The Pragmatic Programmer", "author": "David Thomas"}'

# List all books
curl http://localhost:3000/books

You should see your book returned as JSON. That’s the foundation: a clean CRUD API in about 50 lines of code. The in-memory books array resets on every restart, which is fine for the tutorial but not something you’d ship. Swap it for a real database when you’re ready.

Add an OpenAPI specification

An OpenAPI spec is essential for integrating with Zuplo (and for any production-quality API). You can write one by hand, but swagger-jsdoc generates it from JSDoc comments in your code.

Install the dependencies:

Terminalbash
npm install swagger-jsdoc

Update index.js to add Swagger configuration and JSDoc annotations. Add this near the top of the file, after the express require:

Javascriptjavascript
const swaggerJsdoc = require("swagger-jsdoc");

const swaggerOptions = {
  definition: {
    openapi: "3.0.0",
    info: {
      title: "Bookstore API",
      version: "1.0.0",
      description: "A simple CRUD API for managing books",
    },
    // Relative URL is fine; the spec is served from the same origin as the API.
    // Zuplo's import wizard will ignore this and ask you where the backend lives.
    servers: [{ url: "/" }],
  },
  apis: ["./index.js"],
};

const swaggerSpec = swaggerJsdoc(swaggerOptions);

apis: ["./index.js"] tells swagger-jsdoc where to look for @openapi comments. Keep all your JSDoc in index.js (or expand the glob if you split the routes across files), otherwise the generated spec will be empty.

Then add a route to serve the spec (before app.listen):

Javascriptjavascript
// Serve the OpenAPI spec as JSON
app.get("/openapi.json", (req, res) => {
  res.json(swaggerSpec);
});

This is the URL you’ll hand to Zuplo when you import the spec. You don’t need to serve the docs UI yourself, since Zuplo’s developer portal will render them from the same spec at the end of this tutorial.

Now annotate your endpoints. Add JSDoc comments above each route handler. Here are the list and create endpoints fully annotated:

Javascriptjavascript
/**
 * @openapi
 * /books:
 *   get:
 *     summary: List all books
 *     responses:
 *       200:
 *         description: A list of books
 *         content:
 *           application/json:
 *             schema:
 *               type: array
 *               items:
 *                 $ref: '#/components/schemas/Book'
 */
app.get("/books", (req, res) => {
  res.json(books);
});

/**
 * @openapi
 * /books:
 *   post:
 *     summary: Create a new book
 *     requestBody:
 *       required: true
 *       content:
 *         application/json:
 *           schema:
 *             $ref: '#/components/schemas/BookInput'
 *     responses:
 *       201:
 *         description: The created book
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Book'
 *       400:
 *         description: Missing required fields
 */
app.post("/books", (req, res) => {
  const { title, author } = req.body;
  if (!title || !author) {
    return res.status(400).json({ error: "Title and author are required" });
  }
  const book = { id: nextId++, title, author };
  books.push(book);
  res.status(201).json(book);
});

Add the schema definitions anywhere in the file. swagger-jsdoc scans every @openapi comment and merges them into a single document, so placement doesn’t matter, but keeping shared schemas near the top makes the file easier to scan:

Javascriptjavascript
/**
 * @openapi
 * components:
 *   schemas:
 *     Book:
 *       type: object
 *       properties:
 *         id:
 *           type: integer
 *           description: The book ID
 *         title:
 *           type: string
 *           description: The book title
 *         author:
 *           type: string
 *           description: The book author
 *     BookInput:
 *       type: object
 *       required:
 *         - title
 *         - author
 *       properties:
 *         title:
 *           type: string
 *         author:
 *           type: string
 */

Follow the same pattern for GET /books/:id, PUT /books/:id, and DELETE /books/:id. The :id path parameter needs a parameters block:

Javascriptjavascript
/**
 * @openapi
 * /books/{id}:
 *   get:
 *     summary: Get a book by ID
 *     parameters:
 *       - in: path
 *         name: id
 *         required: true
 *         schema:
 *           type: integer
 *     responses:
 *       200:
 *         description: The book
 *         content:
 *           application/json:
 *             schema:
 *               $ref: '#/components/schemas/Book'
 *       404:
 *         description: Book not found
 */

Restart the server and visit http://localhost:3000/openapi.json to see the generated spec.

Deploy your Express app

Your API needs a public URL so Zuplo can proxy to it. Any Node.js host works (Railway, Render, Fly.io, or a VPS). We’ll use Railway because it’s quick.

First, add a start script to your package.json:

JSONjson
{
  "scripts": {
    "start": "node index.js"
  }
}

Push your code to a GitHub repository, then connect it to Railway:

  1. Go to railway.app and create a new project
  2. Select Deploy from GitHub repo and pick your repository
  3. Railway detects Node.js automatically and deploys

Once deployed, you’ll get a public URL like https://express-bookstore-api-production.up.railway.app. Verify it works by hitting the /books endpoint in your browser.

Save that URL. You’ll need it when configuring Zuplo.

Set up Zuplo as your API gateway

Now for the part that turns your Express API into a production-ready service. Zuplo sits in front of Express as an API gateway, handling rate limiting, authentication, and documentation so you don’t have to build them yourself.

Request flow from client through the Zuplo gateway policy pipeline to the Express.js backend on Railway

Each request from a client hits Zuplo first, flows through the policy pipeline (API key auth, then rate limiting), and only then gets forwarded to your Express app on Railway.

Create a Zuplo project

  1. Sign in at portal.zuplo.com and create a new project
  2. Select an empty project and give it a name (e.g., bookstore-api)
  3. Click Start Building

Import your OpenAPI spec

Instead of creating routes manually, import the OpenAPI spec from your Express app:

  1. In the Zuplo portal, open the Code tab and click routes.oas.json. This is Zuplo’s own config file. It’s an OpenAPI document with extra fields per route for policies and request handlers, and it’s separate from the spec your Express app serves.
  2. Click Import OpenAPI
  3. Download your spec from https://your-railway-url.up.railway.app/openapi.json and upload it

Your endpoints are merged into routes.oas.json, which is what you’ll edit from here on. You should now see all five routes in the route designer.

Zuplo route designer showing all five book routes imported from the OpenAPI spec

Configure the URL Forward Handler

Each imported route needs to know where to send requests. Zuplo’s URL Forward Handler proxies to your Express backend by appending the request path to a base URL.

  1. Go to Settings > Environment Variables
  2. Add a variable called BASE_URL with the value set to your Railway URL (e.g., https://express-bookstore-api-production.up.railway.app)
  3. Back in the route designer, set each route’s Request Handler to URL Forward with the Forward to value set to ${env.BASE_URL}
  4. Save your changes

Test it by clicking the Test button on any route. You’ll see the same responses as hitting Express directly, but now the request flows through Zuplo. The URL you give to consumers is your Zuplo gateway URL, not the Railway URL.

Common mistake:

Your Railway URL is still public, so a caller who knows it can skip the gateway entirely. Lock the origin down so it only accepts traffic from Zuplo, either by checking a shared secret header in your Express app or by using your host’s IP allowlist.

URL Forward Handler

The URL Forward Handler proxies requests to your backend without writing any code. It appends the incoming path to your configured base URL and forwards query parameters automatically.

Zero-code proxyingEnvironment variable supportAutomatic query forwarding

Add rate limiting

Without rate limiting, a single client could overwhelm your Express server. Zuplo’s Rate Limiting policy adds protection with zero code changes.

  1. Open any route in the route designer
  2. Click + Add Policy on the request pipeline
  3. Search for Rate Limiting and select it
  4. Adjust the values. Start with something tight so it’s easy to trigger while you test. These are the options on the policy:
JSONjson
{
  "rateLimitBy": "ip",
  "requestsAllowed": 20,
  "timeWindowMinutes": 1
}
  1. Click OK and save

Test it by sending requests rapidly. After exceeding the limit, you’ll get a 429 Too Many Requests response with a retry-after header.

Apply the policy to every route for consistent protection. You can set different limits per route if some endpoints are more expensive than others.

Pro tip:

Pipeline order matters. When you add the API key policy in the next section, put it above the rate limiter so the limiter can bucket by authenticated consumer instead of IP.

Rate Limiting Policy

Zuplo runs the rate limiter on a globally distributed gateway in front of your origin. You can limit by IP, user, API key, or custom function, all without touching your Express code.

Per-IP or per-key limitingConfigurable time windowsGlobally distributed

Add API key authentication

Rate limiting by IP is a start, but for a real API you want to identify consumers individually. Zuplo’s API Key Authentication policy gives you a managed key system with no auth middleware.

Enable the policy

  1. Open a route and click + Add Policy
  2. Search for API Key Authentication and add it
  3. Accept the default configuration and click OK
  4. Important: drag the API key policy above the rate limiting policy. Authentication runs first so the rate limiter can see the authenticated consumer, which you’ll switch to in the next step

Save. Testing the route now gives you 401 Unauthorized, exactly what you want.

Policy pipeline with api-key-inbound stacked above rate-limit-inbound on the request side

Create an API key consumer

  1. Click the Services tab at the top of the Zuplo portal
  2. Select Configure on the API Key Service
  3. Click Create Consumer
  4. Enter a name (e.g., test-user) and an email address
  5. Save the consumer

Zuplo generates an API key for you. Copy it.

Test authenticated requests

In the route tester, add an Authorization header with the value Bearer YOUR_API_KEY and send the request. You should get a 200. The header name and scheme are configurable if your consumers expect X-API-Key instead.

Without the header (or with an invalid key), you’ll still get 401. Your API is secured.

Now update the rate limiting policy to use "rateLimitBy": "user" instead of "ip". With auth running first, Zuplo attaches the authenticated consumer to the request, and "user" tells the rate limiter to bucket by that consumer. Limits now follow the API key, which is what you want for an API with multiple consumers.

API Key Authentication Policy

Zuplo's built-in API key management handles creation, rotation, and revocation. Consumers can self-serve through the developer portal.

Zero-code authenticationSelf-serve key managementPer-key metadata and permissions

Launch your developer portal

Every API needs documentation, and Zuplo generates it from the OpenAPI spec you imported.

Click Gateway deployed in the portal toolbar and open the Developer Portal link. You’ll see all your routes rendered as interactive docs, complete with the descriptions, request/response schemas, and auth requirements from your OpenAPI spec.

Auto-generated developer portal showing the Create a new book endpoint with request body schema, example payload, and cURL snippet

The portal also includes:

  • An API playground to test endpoints directly
  • API key management for authenticated users to manage their keys
  • Automatic auth documentation reflecting the policies you’ve applied

No extra configuration needed. If your OpenAPI spec has good summaries and descriptions, the portal looks professional out of the box.

Developer Portal

Zuplo automatically generates an interactive developer portal from your OpenAPI spec. It includes API key management, a request playground, and full endpoint documentation.

Auto-generated from OpenAPIBuilt-in API key self-serviceInteractive playground

The complete picture

Here’s what you’ve built:

  • Express.js backend, a CRUD API with OpenAPI annotations
  • Public deployment on Railway (or your preferred host)
  • Zuplo gateway in front of Express, handling cross-cutting concerns
  • Rate limiting, enforced by the gateway
  • API key authentication with managed keys, no custom middleware
  • Developer portal, auto-generated docs with a playground and self-serve key management

The Express app stays focused on business logic. Every operational concern (auth, rate limiting, documentation) lives at the gateway layer.

That separation is the point of an API gateway. Your Express code doesn’t need to know about rate limits or API keys. You can swap policies independently and you don’t have to redeploy Express every time you change an auth strategy or adjust a limit.

Next steps

A few directions from here:

Whatever you’re building with Express.js, Zuplo handles the gateway layer so you can focus on the code that matters.