---
title: "Build a NestJS API with OpenAPI and Zuplo"
description: "Build a REST API with NestJS and TypeScript, generate OpenAPI docs with @nestjs/swagger, and put it behind a Zuplo gateway with rate limiting, API key auth, and a developer portal."
canonicalUrl: "https://zuplo.com/blog/2026/04/13/nestjs-api-tutorial"
pageType: "blog"
date: "2026-04-13"
authors: "martyn"
tags: "TypeScript, API Tooling, Tutorial"
image: "https://zuplo.com/og?text=Build%20a%20NestJS%20API%20with%20OpenAPI%20and%20Zuplo"
---
If you're building APIs with TypeScript, [NestJS](https://nestjs.com/) is
probably on your radar. It's the most popular TypeScript-first backend
framework, used by teams at Roche, Adidas, and Autodesk, and it pairs well with
a gateway that speaks the same language.

You'll build a documented REST API with NestJS, generate an OpenAPI spec with
`@nestjs/swagger`, and use [Zuplo](https://zuplo.com) to add rate limiting, API
key auth, and a developer portal, without writing a line of gateway code.
TypeScript end to end.

<CalloutAudience
  variant="useIf"
  items={[
    `Building REST APIs with TypeScript and NestJS`,
    `Looking for a gateway that complements your TypeScript backend`,
    `Need rate limiting and authentication without rolling your own middleware`,
    `Want auto-generated API documentation from your OpenAPI spec`,
  ]}
/>

## Prerequisites

- [Node.js](https://nodejs.org/) 20 or later
- A free [Railway](https://railway.com/) account (for deployment)
- A free [Zuplo](https://portal.zuplo.com/) account

## Build a CRUD API with NestJS

Let's start by scaffolding a new NestJS project using the CLI. If you don't have
the NestJS CLI installed globally, you can use `npx`:

```bash
npx @nestjs/cli new bookmarks-api
```

Choose `npm` as the package manager when prompted, then move into the project
directory:

```bash
cd bookmarks-api
```

### Install dependencies

We need `@nestjs/swagger` to generate our OpenAPI spec and `class-validator`
plus `class-transformer` for DTO validation:

```bash
npm install @nestjs/swagger class-validator class-transformer
```

### Enable validation globally

Open `src/main.ts` and add the global validation pipe so our DTOs are
automatically validated on incoming requests:

```typescript title="src/main.ts"
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

  const config = new DocumentBuilder()
    .setTitle("Bookmarks API")
    .setDescription("A simple CRUD API for managing bookmarks")
    .setVersion("1.0")
    .build();

  const documentFactory = () => SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("docs", app, documentFactory);

  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
```

This sets up both the validation pipeline and the Swagger documentation
endpoint. When the app is running, you'll be able to access the Swagger UI at
`/docs` and the raw OpenAPI JSON at `/docs-json`.

### Create the bookmark DTO

Create a file for our data transfer objects:

```typescript title="src/bookmark.dto.ts"
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";

export class CreateBookmarkDto {
  @ApiProperty({ description: "The title of the bookmark" })
  @IsString()
  @IsNotEmpty()
  title!: string;

  @ApiProperty({ description: "The URL of the bookmark" })
  @IsUrl()
  @IsNotEmpty()
  url!: string;

  @ApiPropertyOptional({ description: "An optional description" })
  @IsString()
  @IsOptional()
  description?: string;
}

export class UpdateBookmarkDto {
  @ApiPropertyOptional({ description: "The title of the bookmark" })
  @IsString()
  @IsOptional()
  title?: string;

  @ApiPropertyOptional({ description: "The URL of the bookmark" })
  @IsUrl()
  @IsOptional()
  url?: string;

  @ApiPropertyOptional({ description: "An optional description" })
  @IsString()
  @IsOptional()
  description?: string;
}
```

The `!` on `title` and `url` is TypeScript's definite-assignment assertion. It's
required because the scaffolded `tsconfig.json` has
`strictPropertyInitialization`, and class-validator populates these fields at
runtime rather than at construction time.

The `@ApiProperty` and `@ApiPropertyOptional` decorators tell `@nestjs/swagger`
how to describe each field in the generated OpenAPI spec. `class-validator`
decorators like `@IsUrl()` and `@IsNotEmpty()` handle runtime validation.
Invalid requests are rejected with a `400 Bad Request` before they reach your
business logic.

### Create the bookmark service

```typescript title="src/bookmark.service.ts"
import { Injectable, NotFoundException } from "@nestjs/common";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { CreateBookmarkDto, UpdateBookmarkDto } from "./bookmark.dto";

export class Bookmark {
  @ApiProperty()
  id!: number;

  @ApiProperty()
  title!: string;

  @ApiProperty()
  url!: string;

  @ApiPropertyOptional()
  description?: string;

  @ApiProperty()
  createdAt!: string;
}

@Injectable()
export class BookmarkService {
  private bookmarks: Bookmark[] = [];
  private idCounter = 1;

  findAll(): Bookmark[] {
    return this.bookmarks;
  }

  findOne(id: number): Bookmark {
    const bookmark = this.bookmarks.find((b) => b.id === id);
    if (!bookmark) {
      throw new NotFoundException(`Bookmark with ID ${id} not found`);
    }
    return bookmark;
  }

  create(dto: CreateBookmarkDto): Bookmark {
    const bookmark: Bookmark = {
      id: this.idCounter++,
      ...dto,
      createdAt: new Date().toISOString(),
    };
    this.bookmarks.push(bookmark);
    return bookmark;
  }

  update(id: number, dto: UpdateBookmarkDto): Bookmark {
    const bookmark = this.findOne(id);
    Object.assign(bookmark, dto);
    return bookmark;
  }

  remove(id: number): Bookmark {
    const bookmark = this.findOne(id);
    this.bookmarks = this.bookmarks.filter((b) => b.id !== id);
    return bookmark;
  }
}
```

We're using an in-memory array for simplicity. Swap it for Postgres or Mongo in
production. The API contract, and therefore the OpenAPI spec, stays the same.

### Create the bookmark controller

```typescript title="src/bookmark.controller.ts"
import {
  Controller,
  Get,
  Post,
  Patch,
  Delete,
  Body,
  Param,
  ParseIntPipe,
} from "@nestjs/common";
import { ApiTags, ApiOperation, ApiResponse } from "@nestjs/swagger";
import { BookmarkService, Bookmark } from "./bookmark.service";
import { CreateBookmarkDto, UpdateBookmarkDto } from "./bookmark.dto";

@ApiTags("bookmarks")
@Controller("bookmarks")
export class BookmarkController {
  constructor(private readonly bookmarkService: BookmarkService) {}

  @Get()
  @ApiOperation({ summary: "Get all bookmarks" })
  @ApiResponse({ status: 200, type: [Bookmark] })
  findAll(): Bookmark[] {
    return this.bookmarkService.findAll();
  }

  @Get(":id")
  @ApiOperation({ summary: "Get a bookmark by ID" })
  @ApiResponse({ status: 200, type: Bookmark })
  @ApiResponse({ status: 404, description: "Bookmark not found." })
  findOne(@Param("id", ParseIntPipe) id: number): Bookmark {
    return this.bookmarkService.findOne(id);
  }

  @Post()
  @ApiOperation({ summary: "Create a new bookmark" })
  @ApiResponse({ status: 201, type: Bookmark })
  @ApiResponse({ status: 400, description: "Invalid request body." })
  create(@Body() dto: CreateBookmarkDto): Bookmark {
    return this.bookmarkService.create(dto);
  }

  @Patch(":id")
  @ApiOperation({ summary: "Update a bookmark" })
  @ApiResponse({ status: 200, type: Bookmark })
  @ApiResponse({ status: 404, description: "Bookmark not found." })
  update(
    @Param("id", ParseIntPipe) id: number,
    @Body() dto: UpdateBookmarkDto,
  ): Bookmark {
    return this.bookmarkService.update(id, dto);
  }

  @Delete(":id")
  @ApiOperation({ summary: "Delete a bookmark" })
  @ApiResponse({ status: 200, type: Bookmark })
  @ApiResponse({ status: 404, description: "Bookmark not found." })
  remove(@Param("id", ParseIntPipe) id: number): Bookmark {
    return this.bookmarkService.remove(id);
  }
}
```

`@ApiOperation` and `@ApiResponse` give each route a description and a response
schema in the generated spec. Because we pass `type: Bookmark`, the OpenAPI
output includes the full response shape, which Zuplo's developer portal can
render later. `@ApiTags("bookmarks")` groups the endpoints under "bookmarks" in
the Swagger UI.

### Wire it up in the app module

Replace the contents of `src/app.module.ts`:

```typescript title="src/app.module.ts"
import { Module } from "@nestjs/common";
import { BookmarkController } from "./bookmark.controller";
import { BookmarkService } from "./bookmark.service";

@Module({
  controllers: [BookmarkController],
  providers: [BookmarkService],
})
export class AppModule {}
```

### Test it locally

Start the dev server:

```bash
npm run start:dev
```

Visit `http://localhost:3000/docs` to see the Swagger UI with all five endpoints
documented. Test them right from the browser: create a bookmark with POST, then
fetch all bookmarks with GET.

For the next step, grab the raw OpenAPI JSON. From a terminal:

```bash
curl http://localhost:3000/docs-json > openapi.json
```

Or visit `http://localhost:3000/docs-json` in a browser and save the page.
You'll paste this file into Zuplo shortly.

## Deploy to Railway

[Railway](https://railway.com/) makes deploying Node.js apps straightforward.
Push your project to a GitHub repository, then:

1. Go to [railway.com](https://railway.com/) and sign in with your GitHub
   account
2. Click **New Project** and select **Deploy from GitHub repo**
3. Select your `bookmarks-api` repository

Railway detects a Node.js project, runs `npm run build`, and then starts your
app with `npm start`. It also injects a `PORT` environment variable, which is
why `src/main.ts` reads `process.env.PORT`. Your API is live in under a minute.

Once deployed, click **Settings** → **Networking** → **Generate Domain** to get
a public URL. Your OpenAPI spec will be available at
`https://your-app.up.railway.app/docs-json`.

<CalloutTip>
  Railway's free trial includes $5 of credits, which is plenty for testing and
  development.
</CalloutTip>

<CalloutTip variant="mistake">
  Bookmarks live in memory, so every redeploy or container restart wipes the
  list. Fine for testing the gateway flow; for a real deployment, swap the array
  in `BookmarkService` for a database.
</CalloutTip>

## Add an API gateway with Zuplo

You've got a documented API. Now put Zuplo in front of it to handle the
cross-cutting concerns every production API needs: rate limiting, auth, and
developer docs.

The idea is simple. Zuplo reads your OpenAPI spec, creates a route for each
endpoint, and proxies traffic to your NestJS backend. You layer on policies like
rate limiting and API key auth without touching your application code.

### Import your OpenAPI spec

1. Go to [portal.zuplo.com](https://portal.zuplo.com/) and create a new project
2. In the **Code** tab, open `routes.oas.json`. This is Zuplo's gateway config
   file. It's an OpenAPI document where each operation also carries
   Zuplo-specific routing and policy metadata
3. Replace the contents with the OpenAPI JSON from your `openapi.json` file (or
   fetch the live Railway spec at `https://your-app.up.railway.app/docs-json`)
4. Save the file

Zuplo parses the spec and creates a route for every endpoint automatically.

<CalloutTip>
  You can also import your API from the OpenAPI tab in the Zuplo portal, or use
  the [Zuplo CLI](https://zuplo.com/docs/articles/local-development) if you
  prefer working locally.
</CalloutTip>

### Configure the upstream URL

Your routes need to know where to forward requests. Set up an environment
variable so you can easily change it per environment:

1. Go to **Settings** → **Environment Variables**
2. Add a variable called `BASE_URL` with the value set to your Railway URL (e.g.
   `https://your-app.up.railway.app`)
3. Back in the route designer, set the **Forward to** field on each route to
   `${env.BASE_URL}`

Save and test. Your NestJS API should respond through the Zuplo gateway.

<CalloutDoc
  title="Getting Started with Zuplo"
  description={`Learn how to set up your first API gateway with Zuplo, import your OpenAPI specification, and start applying policies.`}
  href="https://zuplo.com/docs/articles/step-1-setup-basic-gateway"
  features={[
    `OpenAPI-native configuration`,
    `Edge deployment in 300+ locations`,
    `Zero-config DDoS protection`,
  ]}
/>

### Add rate limiting

Rate limiting protects your backend from abuse and ensures fair usage across
consumers. With Zuplo, adding it takes about 30 seconds:

1. Open one of your routes in the route designer
2. Click **Add Policy** in the **Policies** section
3. Search for **Rate Limiting** and select it
4. Set `rateLimitBy` to `ip`, `requestsAllowed` to `2`, and `timeWindowMinutes`
   to `1`. These low numbers make the limit easy to trigger while testing

The UI form writes the following config into your policies file. You don't need
to edit it by hand, but this is what's happening under the hood:

```json title="policies.json (excerpt)"
{
  "handler": {
    "export": "RateLimitInboundPolicy",
    "module": "$import(@zuplo/runtime)",
    "options": {
      "rateLimitBy": "ip",
      "requestsAllowed": 2,
      "timeWindowMinutes": 1
    }
  }
}
```

Save and test. A third request within the same minute returns
`429 Too Many Requests`. In production, bump `requestsAllowed` and switch to
`"rateLimitBy": "user"` to rate limit per API key instead of per IP.

<CalloutDoc
  title="Rate Limit Policy"
  description={`Zuplo's Rate Limit policy supports per-IP, per-key, and custom function-based rate limiting with configurable time windows.`}
  href="https://zuplo.com/docs/policies/rate-limit-inbound"
  features={[
    `Per-IP or per-key limiting`,
    `Configurable time windows`,
    `Custom 429 responses`,
  ]}
/>

### Add API key authentication

API keys let you identify and control who accesses your API. Here's how to add
them:

1. On your route, click **Add Policy** again
2. Search for **API Key Authentication** and select it
3. In the policy list for the route, drag the API key policy above the rate
   limiting policy so it runs first. Policies execute top-down.

<CalloutTip variant="mistake">
  Authenticate before rate limiting. If you rate limit first, anonymous traffic
  eats into the per-key budget before auth rejects it, and you can't tell real
  consumers apart in your logs.
</CalloutTip>

Now create an API consumer so you have a key to call with:

1. Open **Services** in the Zuplo portal sidebar
2. Click the default **API Key Service** that ships with new projects
3. Click **Add Consumer**, give it a name (e.g. `test-consumer`), save it, and
   copy the generated key

Call your API without the key. You should get a `401 Unauthorized`. Add an
`Authorization` header with `Bearer YOUR_API_KEY` and the request goes through.

<CalloutDoc
  title="API Key Authentication Policy"
  description={`Zuplo's API Key Authentication validates keys against a managed key store, providing simple but powerful auth for your APIs.`}
  href="https://zuplo.com/docs/policies/api-key-inbound"
  features={[
    `Self-serve API key management`,
    `Per-key rate limiting`,
    `Secret scanning integration`,
  ]}
/>

### Access your developer portal

This is where the TypeScript-to-TypeScript workflow pays off. Your NestJS
decorators describe the API, `@nestjs/swagger` turns them into an OpenAPI spec,
and Zuplo uses that spec to generate a developer portal for you.

Zuplo ships a developer portal alongside every project. It includes:

- **Interactive API documentation** generated from your OpenAPI spec
- **An API playground** where consumers can test endpoints directly
- **Self-serve API key management** so consumers can sign up, create keys, and
  start using your API

To open it, go to your deployment in the Zuplo portal and follow the **Developer
Portal** link.

Because the API key policy sits in your OpenAPI spec, the portal documents the
`Authorization` header requirement for you. No hand-written auth docs.

<CalloutDoc
  title="Developer Portal"
  description={`Zuplo automatically generates a developer portal from your OpenAPI spec with interactive docs, API playground, and key management.`}
  href="https://zuplo.com/docs/dev-portal/introduction"
  features={[
    `Generated from OpenAPI`,
    `Interactive API playground`,
    `Custom branding`,
  ]}
/>

## Why this combination works

NestJS and Zuplo share a design philosophy: convention over configuration,
TypeScript-native, and built around open standards.

- **NestJS** handles your business logic with decorators, dependency injection,
  and modular architecture
- **`@nestjs/swagger`** generates a standards-compliant OpenAPI spec from your
  TypeScript code
- **Zuplo** consumes that spec and handles the cross-cutting concerns (auth,
  rate limiting, monitoring, and developer docs) at the gateway level

Your NestJS codebase stays focused on business logic. No rate-limit middleware,
no API key validation, no docs rendering in the app; Zuplo handles all of that
from the spec.

For microservices it's even better. Configure auth and rate limiting once at the
gateway instead of reimplementing it in every service.

## Wrapping up

You built a NestJS REST API with full CRUD operations, added OpenAPI
documentation with `@nestjs/swagger`, deployed it to Railway, and used Zuplo to
add rate limiting, API key authentication, and a developer portal, all without
modifying your NestJS code.

That's an OpenAPI-first workflow end to end: decorators generate the spec, the
spec drives the gateway, and the gateway handles what your consumers need.