DEV Community

Cover image for How to Structure a REST API for a SaaS Product (Node.js + Express)
Waqar Habib
Waqar Habib Subscriber

Posted on

How to Structure a REST API for a SaaS Product (Node.js + Express)

A REST API for a SaaS product is not the same as a REST API for a simple CRUD app. The scale is different. The multi-tenancy is different. The versioning requirements are different. The error handling expectations from paying customers are different.

I've designed and built dozens of production APIs for US SaaS companies. Here's the structure I arrive at consistently, with explanations for why each decision exists.


Directory Structure First

How you organize your files determines how your team scales. Here's the structure I use for Node.js + Express SaaS APIs:

src/
├── api/
│   ├── v1/
│   │   ├── routes/
│   │   │   ├── users.routes.ts
│   │   │   ├── projects.routes.ts
│   │   │   └── billing.routes.ts
│   │   ├── controllers/
│   │   │   ├── users.controller.ts
│   │   │   └── projects.controller.ts
│   │   └── index.ts
│   └── v2/               # future version
├── services/
│   ├── user.service.ts
│   ├── project.service.ts
│   └── billing.service.ts
├── middleware/
│   ├── auth.middleware.ts
│   ├── tenant.middleware.ts
│   ├── rateLimit.middleware.ts
│   └── errorHandler.middleware.ts
├── models/
│   └── (Prisma schema or Knex models)
├── validators/
│   ├── user.validator.ts
│   └── project.validator.ts
├── utils/
│   ├── response.util.ts
│   └── pagination.util.ts
└── app.ts
Enter fullscreen mode Exit fullscreen mode

The separation between routes, controllers, and services is intentional. Routes define the HTTP shape. Controllers handle request/response translation. Services contain business logic with no HTTP dependencies. This separation means you can test services without HTTP, and swap your HTTP framework without rewriting business logic.


Consistent Response Envelope

Every response from your API should have the same outer shape. This makes frontend integration predictable and lets you add metadata without breaking clients:

// utils/response.util.ts
interface ApiResponse<T> {
  success: boolean;
  data?: T;
  error?: {
    code: string;
    message: string;
    details?: Record<string, string[]>; // field-level validation errors
  };
  meta?: {
    page?: number;
    perPage?: number;
    total?: number;
    requestId?: string;
  };
}

export function successResponse<T>(data: T, meta?: ApiResponse<T>['meta']): ApiResponse<T> {
  return { success: true, data, meta };
}

export function errorResponse(
  code: string,
  message: string,
  details?: Record<string, string[]>
): ApiResponse<never> {
  return { success: false, error: { code, message, details } };
}
Enter fullscreen mode Exit fullscreen mode
// In a controller
async function getUser(req: Request, res: Response) {
  const user = await userService.findById(req.params.id);
  res.json(successResponse(user));
}
Enter fullscreen mode Exit fullscreen mode

Your frontend developers will thank you for this. No more guessing whether errors come in error.message, message, errors[0], or some other shape.


Request Validation Before Controllers Touch Data

Never let unvalidated data reach your service layer. Validate at the route level using Zod schemas:

// validators/project.validator.ts
import { z } from 'zod';

export const CreateProjectSchema = z.object({
  name: z.string().min(1).max(100),
  description: z.string().max(500).optional(),
  visibility: z.enum(['public', 'private']).default('private'),
});

export const UpdateProjectSchema = CreateProjectSchema.partial();

export type CreateProjectDTO = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectDTO = z.infer<typeof UpdateProjectSchema>;
Enter fullscreen mode Exit fullscreen mode
// middleware: validation factory
export function validate(schema: z.ZodSchema) {
  return (req: Request, res: Response, next: NextFunction) => {
    const result = schema.safeParse(req.body);
    if (!result.success) {
      const details = result.error.flatten().fieldErrors;
      return res.status(400).json(errorResponse('VALIDATION_ERROR', 'Invalid request body', details));
    }
    req.body = result.data; // Replace with parsed, type-safe data
    next();
  };
}
Enter fullscreen mode Exit fullscreen mode
// routes/projects.routes.ts
router.post('/', authenticate, validate(CreateProjectSchema), projectController.create);
router.patch('/:id', authenticate, validate(UpdateProjectSchema), projectController.update);
Enter fullscreen mode Exit fullscreen mode

Authentication Middleware with Tenant Scoping

In a multi-tenant SaaS, authentication and tenant resolution happen together:

// middleware/auth.middleware.ts
export async function authenticate(req: Request, res: Response, next: NextFunction) {
  const token = req.headers.authorization?.split(' ')[1];

  if (!token) {
    return res.status(401).json(errorResponse('UNAUTHORIZED', 'No token provided'));
  }

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET) as JWTPayload;
    const user = await userService.findById(payload.userId);
    const tenant = await tenantService.findById(payload.tenantId);

    // Attach to request for downstream handlers
    req.user = user;
    req.tenant = tenant;
    next();
  } catch (err) {
    return res.status(401).json(errorResponse('INVALID_TOKEN', 'Token expired or invalid'));
  }
}
Enter fullscreen mode Exit fullscreen mode

API Versioning From Day One

Most teams skip versioning until they need it. By then it's painful to add. Build it in from the start:

// app.ts
import v1Router from './api/v1';

app.use('/api/v1', v1Router);
// app.use('/api/v2', v2Router); // when ready
Enter fullscreen mode Exit fullscreen mode

When you need to make a breaking change, changing a field name, removing an endpoint, changing authentication, you create /api/v2 and migrate clients gradually. Existing integrations on /api/v1 keep working.

Version in the URL, not in headers. Headers are technically more "RESTful" but URL versioning is more practical: it works with caching, it's visible in logs, and your API clients can hardcode it clearly.


Pagination That Doesn't Break at Scale

Offset pagination (LIMIT 100 OFFSET 1000) is simple but breaks at scale, PostgreSQL has to scan and discard 1000 rows to return your page. For large tables, use cursor-based pagination:

// utils/pagination.util.ts
interface CursorPaginationParams {
  cursor?: string;   // ID of last item on previous page
  limit: number;
}

interface CursorPaginationResult<T> {
  items: T[];
  nextCursor: string | null;
  hasMore: boolean;
}

export async function paginateWithCursor<T extends { id: string }>(
  query: (params: { cursor?: string; limit: number }) => Promise<T[]>,
  params: CursorPaginationParams
): Promise<CursorPaginationResult<T>> {
  const { cursor, limit } = params;
  const items = await query({ cursor, limit: limit + 1 }); // fetch one extra

  const hasMore = items.length > limit;
  const pageItems = hasMore ? items.slice(0, limit) : items;
  const nextCursor = hasMore ? pageItems[pageItems.length - 1].id : null;

  return { items: pageItems, nextCursor, hasMore };
}
Enter fullscreen mode Exit fullscreen mode
-- Cursor-based query: much faster than OFFSET
SELECT * FROM projects
WHERE tenant_id = $1
  AND (id > $2 OR $2 IS NULL)
ORDER BY id ASC
LIMIT $3;
Enter fullscreen mode Exit fullscreen mode

Global Error Handler

Never let unhandled errors leak stack traces to API clients. One global error handler catches everything:

// middleware/errorHandler.middleware.ts
export function errorHandler(
  err: Error,
  req: Request,
  res: Response,
  _next: NextFunction
) {
  // Log full error internally
  logger.error({ err, url: req.url, method: req.method, tenantId: req.tenant?.id });

  // Known application errors
  if (err instanceof AppError) {
    return res.status(err.statusCode).json(errorResponse(err.code, err.message));
  }

  // Prisma / database errors
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === 'P2002') {
      return res.status(409).json(errorResponse('DUPLICATE_ENTRY', 'Resource already exists'));
    }
  }

  // Unknown errors: generic message to client, full details in logs
  return res.status(500).json(
    errorResponse('INTERNAL_ERROR', 'Something went wrong. Our team has been notified.')
  );
}

// Register last in app.ts
app.use(errorHandler);
Enter fullscreen mode Exit fullscreen mode

US enterprise clients and compliance frameworks (SOC 2, HIPAA) expect that your API never leaks internal implementation details, stack traces, SQL queries, server paths, in error responses. This handler ensures that.


Rate Limiting Per Tenant

Global rate limiting is not enough for a SaaS product. One tenant hammering your API shouldn't affect others:

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

export function tenantRateLimit(windowMs: number, max: number) {
  return rateLimit({
    windowMs,
    max,
    keyGenerator: (req) => `tenant:${req.tenant?.id}:${req.ip}`,
    store: new RedisStore({ client: redisClient }),
    handler: (req, res) => {
      res.status(429).json(errorResponse(
        'RATE_LIMITED',
        `Too many requests. Limit is ${max} per ${windowMs / 1000}s.`
      ));
    }
  });
}

// Apply different limits to different route groups
router.use('/api/v1/webhooks', tenantRateLimit(60_000, 10));   // strict
router.use('/api/v1',          tenantRateLimit(60_000, 1000)); // standard
Enter fullscreen mode Exit fullscreen mode

This structure has served well across dozens of production SaaS APIs serving US business customers. The key philosophy: make the right thing easy and the wrong thing hard. Validation middleware means you can't skip validation. A global error handler means you can't accidentally leak internals. A response envelope means you can't return an inconsistent shape.

If you're building a SaaS API and want it done right from the start, API architecture and development is one of the core services I offer at waqarhabib.com/services/api-development.


Originally published at waqarhabib.com

Top comments (0)