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
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 } };
}
// In a controller
async function getUser(req: Request, res: Response) {
const user = await userService.findById(req.params.id);
res.json(successResponse(user));
}
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>;
// 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();
};
}
// routes/projects.routes.ts
router.post('/', authenticate, validate(CreateProjectSchema), projectController.create);
router.patch('/:id', authenticate, validate(UpdateProjectSchema), projectController.update);
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'));
}
}
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
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 };
}
-- 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;
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);
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
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)