---
title: "Build and Secure an Express.js REST API with Zuplo"
description: "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."
canonicalUrl: "https://zuplo.com/blog/2026/03/20/expressjs-api-tutorial"
pageType: "blog"
date: "2026-03-20"
authors: "nate"
tags: "Tutorial, Node.js, API Tooling"
image: "https://zuplo.com/og?text=Build%20and%20Secure%20an%20Express.js%20REST%20API%20with%20Zuplo"
---
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.

<CalloutAudience
  variant="useIf"
  items={[
    `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](https://railway.app) account (or any Node.js hosting provider)
- A [Zuplo](https://portal.zuplo.com/signup?utm_source=blog) account (free)

## Build a CRUD API with Express.js

Start by creating a new project directory and initializing it:

```bash
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 title="index.js"
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:

```bash
node index.js
```

Test it with curl:

```bash
# 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:

```bash
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:

```javascript title="index.js"
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`):

```javascript
// 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:

```javascript
/**
 * @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:

```javascript
/**
 * @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:

```javascript
/**
 * @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`:

```json title="package.json"
{
  "scripts": {
    "start": "node index.js"
  }
}
```

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

1. Go to [railway.app](https://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](/blog-images/2026-03-20-expressjs-api-tutorial/diagram-1.png)

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](https://portal.zuplo.com/signup?utm_source=blog) 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](/blog-images/2026-03-20-expressjs-api-tutorial/routes-imported.png)

### Configure the URL Forward Handler

Each imported route needs to know where to send requests. Zuplo's
[URL Forward Handler](https://zuplo.com/docs/handlers/url-forward) 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.

<CalloutTip variant="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.
</CalloutTip>

<CalloutDoc
  title="URL Forward Handler"
  description={`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.`}
  href="https://zuplo.com/docs/handlers/url-forward"
  features={[
    `Zero-code proxying`,
    `Environment variable support`,
    `Automatic query forwarding`,
  ]}
/>

## Add rate limiting

Without rate limiting, a single client could overwhelm your Express server.
Zuplo's
[Rate Limiting policy](https://zuplo.com/docs/policies/rate-limit-inbound) 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:

```json
{
  "rateLimitBy": "ip",
  "requestsAllowed": 20,
  "timeWindowMinutes": 1
}
```

5. 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.

<CalloutTip>
  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.
</CalloutTip>

<CalloutDoc
  title="Rate Limiting Policy"
  description={`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.`}
  href="https://zuplo.com/docs/policies/rate-limit-inbound"
  features={[
    `Per-IP or per-key limiting`,
    `Configurable time windows`,
    `Globally 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](https://zuplo.com/docs/policies/api-key-inbound)
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](/blog-images/2026-03-20-expressjs-api-tutorial/policy-pipeline.png)

### 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.

<CalloutDoc
  title="API Key Authentication Policy"
  description={`Zuplo's built-in API key management handles creation, rotation, and revocation. Consumers can self-serve through the developer portal.`}
  href="https://zuplo.com/docs/policies/api-key-inbound"
  features={[
    `Zero-code authentication`,
    `Self-serve key management`,
    `Per-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](/blog-images/2026-03-20-expressjs-api-tutorial/dev-portal.png)

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.

<CalloutDoc
  title="Developer Portal"
  description={`Zuplo automatically generates an interactive developer portal from your OpenAPI spec. It includes API key management, a request playground, and full endpoint documentation.`}
  href="https://zuplo.com/docs/dev-portal/introduction"
  features={[
    `Auto-generated from OpenAPI`,
    `Built-in API key self-service`,
    `Interactive 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:

- **Add request validation** with Zuplo's
  [Request Validation policy](https://zuplo.com/docs/policies/request-validation-inbound)
  to enforce body schemas at the gateway
- **Set up per-consumer rate limits** with
  [dynamic rate limiting](https://zuplo.com/docs/articles/step-5-dynamic-rate-limiting)
  based on API key metadata
- **Monetize your API** with Zuplo's
  [monetization features](https://zuplo.com/docs/articles/monetization/monetization-policy)
  to attach billing plans to keys
- **Develop locally** with the [Zuplo CLI](https://zuplo.com/docs/cli/overview)
  for a git-based workflow

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