Back to Journal
SaaS Engineering

How to Build SaaS API Design Using Nestjs

Step-by-step tutorial for building SaaS API Design with Nestjs, from project setup through deployment.

Muneer Puthiya Purayil 15 min read

NestJS brings the structure and patterns of enterprise frameworks like Spring Boot to the Node.js ecosystem. Its opinionated architecture—modules, providers, guards, pipes, and interceptors—provides a natural foundation for building SaaS APIs that scale with your team. This tutorial walks you through building a complete multi-tenant SaaS API with NestJS from scratch.

By the end, you'll have a production-ready API with JWT authentication, tenant isolation, cursor-based pagination, webhook delivery, and automated testing.

Prerequisites

  • Node.js 20+
  • PostgreSQL 15+
  • Redis 7+
  • Basic TypeScript knowledge

Project Setup

Bootstrap a new NestJS project:

bash
1npm i -g @nestjs/cli
2nest new saas-api --strict --package-manager npm
3cd saas-api
4 
5npm install @nestjs/config @nestjs/jwt @nestjs/passport passport passport-jwt \
6 @prisma/client class-validator class-transformer \
7 @nestjs/throttler ioredis helmet
8npm install -D prisma @types/passport-jwt
9 

Step 1: Configuration Module

Create environment-based configuration:

typescript
1// src/config/configuration.ts
2export default () => ({
3 port: parseInt(process.env.PORT, 10) || 3001,
4 database: {
5 url: process.env.DATABASE_URL,
6 },
7 redis: {
8 url: process.env.REDIS_URL,
9 },
10 jwt: {
11 secret: process.env.JWT_SECRET,
12 accessExpiresIn: '15m',
13 refreshExpiresIn: '30d',
14 },
15 cors: {
16 origins: process.env.CORS_ORIGINS?.split(',') || ['http://localhost:3000'],
17 },
18});
19 
20// src/config/validation.ts
21import { plainToInstance, Type } from 'class-transformer';
22import { IsNumber, IsString, validateSync } from 'class-validator';
23 
24class EnvironmentVariables {
25 @IsNumber()
26 @Type(() => Number)
27 PORT: number;
28 
29 @IsString()
30 DATABASE_URL: string;
31 
32 @IsString()
33 REDIS_URL: string;
34 
35 @IsString()
36 JWT_SECRET: string;
37}
38 
39export function validate(config: Record<string, unknown>) {
40 const validatedConfig = plainToInstance(EnvironmentVariables, config, {
41 enableImplicitConversion: true,
42 });
43 const errors = validateSync(validatedConfig, {
44 skipMissingProperties: false,
45 });
46 if (errors.length > 0) {
47 throw new Error(`Config validation failed: ${errors.toString()}`);
48 }
49 return validatedConfig;
50}
51 

Step 2: Prisma Schema

Define your database models:

prisma
1// prisma/schema.prisma
2generator client {
3 provider = "prisma-client-js"
4}
5 
6datasource db {
7 provider = "postgresql"
8 url = env("DATABASE_URL")
9}
10 
11enum OrderStatus {
12 PENDING
13 CONFIRMED
14 PROCESSING
15 COMPLETED
16 CANCELLED
17}
18 
19model Tenant {
20 id String @id @default(uuid())
21 name String
22 plan String @default("free")
23 createdAt DateTime @default(now()) @map("created_at")
24 
25 users User[]
26 orders Order[]
27 
28 @@map("tenants")
29}
30 
31model User {
32 id String @id @default(uuid())
33 tenantId String @map("tenant_id")
34 email String @unique
35 password String
36 role String @default("member")
37 createdAt DateTime @default(now()) @map("created_at")
38 
39 tenant Tenant @relation(fields: [tenantId], references: [id])
40 
41 @@map("users")
42}
43 
44model Order {
45 id String @id @default(uuid())
46 tenantId String @map("tenant_id")
47 customerId String @map("customer_id")
48 status OrderStatus @default(PENDING)
49 totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
50 currency String @db.VarChar(3)
51 notes String? @db.VarChar(500)
52 createdAt DateTime @default(now()) @map("created_at")
53 updatedAt DateTime @updatedAt @map("updated_at")
54 
55 tenant Tenant @relation(fields: [tenantId], references: [id])
56 items OrderItem[]
57 
58 @@index([tenantId, createdAt(sort: Desc)])
59 @@map("orders")
60}
61 
62model OrderItem {
63 id String @id @default(uuid())
64 orderId String @map("order_id")
65 productId String @map("product_id")
66 productName String @map("product_name") @db.VarChar(200)
67 quantity Int
68 unitPrice Decimal @map("unit_price") @db.Decimal(10, 2)
69 
70 order Order @relation(fields: [orderId], references: [id], onDelete: Cascade)
71 
72 @@map("order_items")
73}
74 

Step 3: Database Module

Create a reusable Prisma service:

typescript
1// src/database/prisma.service.ts
2import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
3import { PrismaClient } from '@prisma/client';
4 
5@Injectable()
6export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
7 async onModuleInit() {
8 await this.$connect();
9 }
10 
11 async onModuleDestroy() {
12 await this.$disconnect();
13 }
14}
15 
16// src/database/database.module.ts
17import { Global, Module } from '@nestjs/common';
18import { PrismaService } from './prisma.service';
19 
20@Global()
21@Module({
22 providers: [PrismaService],
23 exports: [PrismaService],
24})
25export class DatabaseModule {}
26 

Need a second opinion on your saas engineering architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Step 4: Authentication

Implement JWT authentication with guards:

typescript
1// src/auth/auth.service.ts
2import { Injectable, UnauthorizedException } from '@nestjs/common';
3import { JwtService } from '@nestjs/jwt';
4import * as bcrypt from 'bcrypt';
5import { PrismaService } from '../database/prisma.service';
6 
7interface TokenPayload {
8 sub: string;
9 tenantId: string;
10 role: string;
11}
12 
13@Injectable()
14export class AuthService {
15 constructor(
16 private readonly prisma: PrismaService,
17 private readonly jwt: JwtService,
18 ) {}
19 
20 async login(email: string, password: string) {
21 const user = await this.prisma.user.findUnique({
22 where: { email },
23 });
24 
25 if (!user || !(await bcrypt.compare(password, user.password))) {
26 throw new UnauthorizedException('Invalid credentials');
27 }
28 
29 return this.generateTokens({
30 sub: user.id,
31 tenantId: user.tenantId,
32 role: user.role,
33 });
34 }
35 
36 async refreshTokens(refreshToken: string) {
37 try {
38 const payload = this.jwt.verify<TokenPayload & { type: string }>(refreshToken);
39 if (payload.type !== 'refresh') {
40 throw new UnauthorizedException('Invalid token type');
41 }
42 return this.generateTokens({
43 sub: payload.sub,
44 tenantId: payload.tenantId,
45 role: payload.role,
46 });
47 } catch {
48 throw new UnauthorizedException('Invalid refresh token');
49 }
50 }
51 
52 private generateTokens(payload: TokenPayload) {
53 return {
54 access_token: this.jwt.sign(
55 { ...payload, type: 'access' },
56 { expiresIn: '15m' },
57 ),
58 refresh_token: this.jwt.sign(
59 { ...payload, type: 'refresh' },
60 { expiresIn: '30d' },
61 ),
62 expires_in: 900,
63 };
64 }
65}
66 
67// src/auth/jwt.guard.ts
68import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
69import { JwtService } from '@nestjs/jwt';
70 
71@Injectable()
72export class JwtAuthGuard implements CanActivate {
73 constructor(private readonly jwt: JwtService) {}
74 
75 canActivate(context: ExecutionContext): boolean {
76 const request = context.switchToHttp().getRequest();
77 const authHeader = request.headers.authorization;
78 
79 if (!authHeader?.startsWith('Bearer ')) {
80 throw new UnauthorizedException('Missing authorization header');
81 }
82 
83 try {
84 const token = authHeader.slice(7);
85 const payload = this.jwt.verify(token);
86 
87 if (payload.type !== 'access') {
88 throw new UnauthorizedException('Invalid token type');
89 }
90 
91 request.user = {
92 id: payload.sub,
93 tenantId: payload.tenantId,
94 role: payload.role,
95 };
96 
97 return true;
98 } catch {
99 throw new UnauthorizedException('Invalid or expired token');
100 }
101 }
102}
103 
104// src/auth/decorators.ts
105import { createParamDecorator, ExecutionContext } from '@nestjs/common';
106 
107export const CurrentUser = createParamDecorator(
108 (data: string, ctx: ExecutionContext) => {
109 const request = ctx.switchToHttp().getRequest();
110 return data ? request.user?.[data] : request.user;
111 },
112);
113 
114export const TenantId = createParamDecorator(
115 (_data: unknown, ctx: ExecutionContext) => {
116 const request = ctx.switchToHttp().getRequest();
117 return request.user?.tenantId;
118 },
119);
120 

Step 5: Orders Module

DTOs

typescript
1// src/orders/dto/create-order.dto.ts
2import { Type } from 'class-transformer';
3import {
4 IsString, IsArray, IsNumber, IsOptional,
5 Min, MinLength, MaxLength, ArrayMinSize,
6 ValidateNested, IsUUID, Length,
7} from 'class-validator';
8 
9export class OrderItemDto {
10 @IsString()
11 @MinLength(1)
12 productId: string;
13 
14 @IsString()
15 @MinLength(1)
16 @MaxLength(200)
17 productName: string;
18 
19 @IsNumber()
20 @Min(1)
21 quantity: number;
22 
23 @IsNumber()
24 @Min(0.01)
25 unitPrice: number;
26}
27 
28export class CreateOrderDto {
29 @IsUUID()
30 customerId: string;
31 
32 @IsArray()
33 @ArrayMinSize(1)
34 @ValidateNested({ each: true })
35 @Type(() => OrderItemDto)
36 items: OrderItemDto[];
37 
38 @IsString()
39 @Length(3, 3)
40 currency: string;
41 
42 @IsOptional()
43 @IsString()
44 @MaxLength(500)
45 notes?: string;
46}
47 
48// src/orders/dto/list-orders.dto.ts
49export class ListOrdersDto {
50 @IsOptional()
51 @IsString()
52 cursor?: string;
53 
54 @IsOptional()
55 @IsNumber()
56 @Min(1)
57 @Type(() => Number)
58 limit?: number = 20;
59 
60 @IsOptional()
61 @IsString()
62 status?: string;
63}
64 

Repository

typescript
1// src/orders/orders.repository.ts
2import { Injectable } from '@nestjs/common';
3import { PrismaService } from '../database/prisma.service';
4import { Prisma } from '@prisma/client';
5 
6@Injectable()
7export class OrdersRepository {
8 constructor(private readonly prisma: PrismaService) {}
9 
10 async create(data: Prisma.OrderCreateInput) {
11 return this.prisma.order.create({
12 data,
13 include: { items: true },
14 });
15 }
16 
17 async findById(tenantId: string, orderId: string) {
18 return this.prisma.order.findFirst({
19 where: { id: orderId, tenantId },
20 include: { items: true },
21 });
22 }
23 
24 async list(
25 tenantId: string,
26 options: { cursor?: string; limit?: number; status?: string },
27 ) {
28 const limit = Math.min(options.limit ?? 20, 100);
29 const where: Prisma.OrderWhereInput = { tenantId };
30 
31 if (options.status) {
32 where.status = options.status as any;
33 }
34 
35 const orders = await this.prisma.order.findMany({
36 where,
37 include: { items: true },
38 orderBy: { createdAt: 'desc' },
39 take: limit + 1,
40 ...(options.cursor && {
41 cursor: { id: options.cursor },
42 skip: 1,
43 }),
44 });
45 
46 const hasMore = orders.length > limit;
47 const data = hasMore ? orders.slice(0, limit) : orders;
48 const nextCursor = hasMore ? data[data.length - 1].id : null;
49 
50 return { data, nextCursor, hasMore };
51 }
52 
53 async updateStatus(tenantId: string, orderId: string, status: string) {
54 return this.prisma.order.update({
55 where: { id: orderId, tenantId },
56 data: { status: status as any },
57 include: { items: true },
58 });
59 }
60}
61 

Service

typescript
1// src/orders/orders.service.ts
2import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
3import { OrdersRepository } from './orders.repository';
4import { CreateOrderDto } from './dto/create-order.dto';
5 
6@Injectable()
7export class OrdersService {
8 private readonly validTransitions: Record<string, string[]> = {
9 PENDING: ['CONFIRMED', 'CANCELLED'],
10 CONFIRMED: ['PROCESSING', 'CANCELLED'],
11 PROCESSING: ['COMPLETED', 'CANCELLED'],
12 };
13 
14 constructor(private readonly ordersRepo: OrdersRepository) {}
15 
16 async create(tenantId: string, dto: CreateOrderDto) {
17 const totalAmount = dto.items.reduce(
18 (sum, item) => sum + item.unitPrice * item.quantity,
19 0,
20 );
21 
22 return this.ordersRepo.create({
23 tenant: { connect: { id: tenantId } },
24 customerId: dto.customerId,
25 totalAmount,
26 currency: dto.currency.toUpperCase(),
27 notes: dto.notes,
28 items: {
29 create: dto.items.map(item => ({
30 productId: item.productId,
31 productName: item.productName,
32 quantity: item.quantity,
33 unitPrice: item.unitPrice,
34 })),
35 },
36 });
37 }
38 
39 async findOne(tenantId: string, orderId: string) {
40 const order = await this.ordersRepo.findById(tenantId, orderId);
41 if (!order) {
42 throw new NotFoundException(`Order '${orderId}' not found`);
43 }
44 return order;
45 }
46 
47 async list(tenantId: string, options: { cursor?: string; limit?: number; status?: string }) {
48 return this.ordersRepo.list(tenantId, options);
49 }
50 
51 async updateStatus(tenantId: string, orderId: string, newStatus: string) {
52 const order = await this.findOne(tenantId, orderId);
53 
54 const allowed = this.validTransitions[order.status] ?? [];
55 if (!allowed.includes(newStatus)) {
56 throw new BadRequestException(
57 `Cannot transition from ${order.status} to ${newStatus}`,
58 );
59 }
60 
61 return this.ordersRepo.updateStatus(tenantId, orderId, newStatus);
62 }
63}
64 

Controller

typescript
1// src/orders/orders.controller.ts
2import {
3 Controller, Get, Post, Patch, Body, Param, Query,
4 UseGuards, HttpCode, HttpStatus,
5} from '@nestjs/common';
6import { JwtAuthGuard } from '../auth/jwt.guard';
7import { TenantId } from '../auth/decorators';
8import { OrdersService } from './orders.service';
9import { CreateOrderDto } from './dto/create-order.dto';
10import { ListOrdersDto } from './dto/list-orders.dto';
11import { UpdateOrderStatusDto } from './dto/update-order-status.dto';
12 
13@Controller('api/v1/orders')
14@UseGuards(JwtAuthGuard)
15export class OrdersController {
16 constructor(private readonly ordersService: OrdersService) {}
17 
18 @Post()
19 @HttpCode(HttpStatus.CREATED)
20 async create(
21 @TenantId() tenantId: string,
22 @Body() dto: CreateOrderDto,
23 ) {
24 const order = await this.ordersService.create(tenantId, dto);
25 return { data: order };
26 }
27 
28 @Get()
29 async list(
30 @TenantId() tenantId: string,
31 @Query() query: ListOrdersDto,
32 ) {
33 return this.ordersService.list(tenantId, query);
34 }
35 
36 @Get(':id')
37 async findOne(
38 @TenantId() tenantId: string,
39 @Param('id') id: string,
40 ) {
41 const order = await this.ordersService.findOne(tenantId, id);
42 return { data: order };
43 }
44 
45 @Patch(':id/status')
46 async updateStatus(
47 @TenantId() tenantId: string,
48 @Param('id') id: string,
49 @Body() dto: UpdateOrderStatusDto,
50 ) {
51 const order = await this.ordersService.updateStatus(tenantId, id, dto.status);
52 return { data: order };
53 }
54}
55 

Step 6: Global Error Filter

typescript
1// src/filters/http-exception.filter.ts
2import {
3 ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus,
4} from '@nestjs/common';
5import { Response } from 'express';
6 
7@Catch()
8export class AllExceptionsFilter implements ExceptionFilter {
9 catch(exception: unknown, host: ArgumentsHost) {
10 const ctx = host.switchToHttp();
11 const response = ctx.getResponse<Response>();
12 
13 if (exception instanceof HttpException) {
14 const status = exception.getStatus();
15 const exceptionResponse = exception.getResponse();
16 
17 const detail = typeof exceptionResponse === 'string'
18 ? exceptionResponse
19 : (exceptionResponse as any).message;
20 
21 return response.status(status).json({
22 type: HttpStatus[status]?.toLowerCase().replace(/_/g, '-') || 'error',
23 title: HttpStatus[status] || 'Error',
24 status,
25 detail: Array.isArray(detail) ? detail.join('; ') : detail,
26 });
27 }
28 
29 console.error('Unhandled exception:', exception);
30 
31 return response.status(500).json({
32 type: 'internal-server-error',
33 title: 'Internal Server Error',
34 status: 500,
35 detail: 'An unexpected error occurred',
36 });
37 }
38}
39 

Step 7: App Module and Bootstrap

typescript
1// src/app.module.ts
2import { Module } from '@nestjs/common';
3import { ConfigModule } from '@nestjs/config';
4import { JwtModule } from '@nestjs/jwt';
5import { ThrottlerModule } from '@nestjs/throttler';
6import { DatabaseModule } from './database/database.module';
7import { AuthModule } from './auth/auth.module';
8import { OrdersModule } from './orders/orders.module';
9import configuration from './config/configuration';
10import { validate } from './config/validation';
11 
12@Module({
13 imports: [
14 ConfigModule.forRoot({
15 load: [configuration],
16 validate,
17 isGlobal: true,
18 }),
19 JwtModule.register({
20 global: true,
21 secret: process.env.JWT_SECRET,
22 }),
23 ThrottlerModule.forRoot([{
24 ttl: 60000,
25 limit: 100,
26 }]),
27 DatabaseModule,
28 AuthModule,
29 OrdersModule,
30 ],
31})
32export class AppModule {}
33 
34// src/main.ts
35import { NestFactory } from '@nestjs/core';
36import { ValidationPipe } from '@nestjs/common';
37import helmet from 'helmet';
38import { AppModule } from './app.module';
39import { AllExceptionsFilter } from './filters/http-exception.filter';
40 
41async function bootstrap() {
42 const app = await NestFactory.create(AppModule);
43 
44 app.use(helmet());
45 app.enableCors({ origin: process.env.CORS_ORIGINS?.split(',') });
46 app.useGlobalPipes(new ValidationPipe({
47 whitelist: true,
48 transform: true,
49 forbidNonWhitelisted: true,
50 }));
51 app.useGlobalFilters(new AllExceptionsFilter());
52 
53 await app.listen(process.env.PORT || 3001);
54}
55bootstrap();
56 

Step 8: Testing

typescript
1// test/orders.e2e-spec.ts
2import { Test } from '@nestjs/testing';
3import { INestApplication, ValidationPipe } from '@nestjs/common';
4import * as request from 'supertest';
5import { AppModule } from '../src/app.module';
6import { JwtService } from '@nestjs/jwt';
7 
8describe('Orders (e2e)', () => {
9 let app: INestApplication;
10 let jwtService: JwtService;
11 let authToken: string;
12 
13 beforeAll(async () => {
14 const moduleRef = await Test.createTestingModule({
15 imports: [AppModule],
16 }).compile();
17 
18 app = moduleRef.createNestApplication();
19 app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
20 await app.init();
21 
22 jwtService = moduleRef.get(JwtService);
23 authToken = jwtService.sign({
24 sub: 'test-user',
25 tenantId: 'test-tenant',
26 role: 'admin',
27 type: 'access',
28 });
29 });
30 
31 it('POST /api/v1/orders creates an order', async () => {
32 const response = await request(app.getHttpServer())
33 .post('/api/v1/orders')
34 .set('Authorization', `Bearer ${authToken}`)
35 .send({
36 customerId: '550e8400-e29b-41d4-a716-446655440000',
37 items: [
38 {
39 productId: 'prod-1',
40 productName: 'Widget',
41 quantity: 2,
42 unitPrice: 29.99,
43 },
44 ],
45 currency: 'USD',
46 })
47 .expect(201);
48 
49 expect(response.body.data.status).toBe('PENDING');
50 expect(response.body.data.items).toHaveLength(1);
51 });
52 
53 it('GET /api/v1/orders returns paginated results', async () => {
54 const response = await request(app.getHttpServer())
55 .get('/api/v1/orders?limit=10')
56 .set('Authorization', `Bearer ${authToken}`)
57 .expect(200);
58 
59 expect(response.body).toHaveProperty('data');
60 expect(response.body).toHaveProperty('hasMore');
61 });
62 
63 afterAll(async () => {
64 await app.close();
65 });
66});
67 

Conclusion

NestJS provides the ideal framework for building SaaS APIs when you want structure and conventions without sacrificing TypeScript's flexibility. Its module system naturally enforces separation of concerns, guards handle authentication declaratively, and pipes validate input transparently.

The architecture built in this tutorial—modules per domain, repositories for data access, services for business logic, and controllers for HTTP handling—mirrors enterprise patterns that scale with your team. As your API grows, adding new features means adding new modules, not modifying existing ones.

NestJS's dependency injection container makes testing straightforward. Mock any provider by overriding it in the test module, and NestJS handles the rest. This testability, combined with TypeScript's type safety and Prisma's generated types, gives you confidence that changes in one area won't break another.

FAQ

Need expert help?

Building with saas engineering?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026