Zuplo
TypeScript

Build a NestJS API with OpenAPI and Zuplo

Martyn DaviesMartyn Davies
April 13, 2026
10 min read

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.

If you’re building APIs with TypeScript, NestJS 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 to add rate limiting, API key auth, and a developer portal, without writing a line of gateway code. TypeScript end to end.

Use this approach if you're:
  • 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

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:

Terminalbash
npx @nestjs/cli new bookmarks-api

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

Terminalbash
cd bookmarks-api

Install dependencies

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

Terminalbash
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:

TypeScripttypescript
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:

TypeScripttypescript
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
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
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:

TypeScripttypescript
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:

Terminalbash
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:

Terminalbash
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 makes deploying Node.js apps straightforward. Push your project to a GitHub repository, then:

  1. Go to 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 SettingsNetworkingGenerate Domain to get a public URL. Your OpenAPI spec will be available at https://your-app.up.railway.app/docs-json.

Pro tip:

Railway’s free trial includes $5 of credits, which is plenty for testing and development.

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

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

Pro tip:

You can also import your API from the OpenAPI tab in the Zuplo portal, or use the Zuplo CLI if you prefer working locally.

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

Getting Started with Zuplo

Learn how to set up your first API gateway with Zuplo, import your OpenAPI specification, and start applying policies.

OpenAPI-native configurationEdge deployment in 300+ locationsZero-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:

JSONjson
{
  "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.

Rate Limit Policy

Zuplo's Rate Limit policy supports per-IP, per-key, and custom function-based rate limiting with configurable time windows.

Per-IP or per-key limitingConfigurable time windowsCustom 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.

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

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.

API Key Authentication Policy

Zuplo's API Key Authentication validates keys against a managed key store, providing simple but powerful auth for your APIs.

Self-serve API key managementPer-key rate limitingSecret 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.

Developer Portal

Zuplo automatically generates a developer portal from your OpenAPI spec with interactive docs, API playground, and key management.

Generated from OpenAPIInteractive API playgroundCustom 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.