DEV Community

Ansh Sheladiya
Ansh Sheladiya

Posted on

JavaScript for SaaS Development: Build Scalable Multi-Tenant Applications

Software as a Service (SaaS) has become one of the most practical ways to deliver software, generate recurring revenue, and scale products without distributing installations to individual customers. From project management platforms to CRM systems, modern SaaS applications rely on reliable APIs, secure authentication, subscription management, and efficient data handling.

JavaScript is an excellent choice for building SaaS products because developers can use the same language across the frontend, backend, and automation layers. With Node.js, Express, and a database such as PostgreSQL or MongoDB, teams can build complete applications while keeping their development workflow consistent.

However, building a SaaS product involves more than creating CRUD endpoints. You must consider tenant isolation, access control, usage limits, error handling, and maintainability from the beginning. In this article, we will explore these concepts by building a small JavaScript SaaS API that demonstrates tenant-aware data access, subscription limits, and structured request handling.

Building a Multi-Tenant SaaS API with JavaScript

A multi-tenant SaaS application allows multiple customers, often called tenants, to use the same application infrastructure while keeping their data logically separated. For example, a project management platform might serve dozens of companies through one API, but each company should only access its own projects. Tenant isolation must be enforced by the backend rather than relying on frontend filters or client-provided identifiers.

Subscription plans introduce another important requirement: different customers may have different usage limits. A free plan might allow three projects, while a professional plan could allow fifty. These limits should be checked on the server whenever a customer attempts to create a resource, and the API should return a clear response when the limit is reached.

The following example uses Node.js and Express to demonstrate these ideas in a small, runnable application. It stores data in memory so that you can focus on the application architecture without configuring a database. In a production system, replace the in-memory storage with a database, derive tenant identity from verified authentication credentials, and enforce tenant isolation at the database query level.

The example also separates business rules from HTTP handlers, validates incoming data, and centralizes error responses. This structure makes the application easier to test and extend as features such as billing, audit logs, team permissions, and usage analytics are introduced.

const express = require("express");

const app = express();
const PORT = process.env.PORT || 3000;

// Parse incoming JSON request bodies.
app.use(express.json());

// Demo subscription plans and their project creation limits.
const plans = {
  free: { name: "Free", maxProjects: 3 },
  pro: { name: "Pro", maxProjects: 50 },
};

// In-memory storage is convenient for a demo, but data disappears on restart.
const tenants = new Map([
  ["tenant-acme", { id: "tenant-acme", name: "Acme Inc", plan: "free" }],
  ["tenant-nova", { id: "tenant-nova", name: "Nova Labs", plan: "pro" }],
]);

const projects = [];
let nextProjectId = 1;

// Resolve a tenant from a demo request header.
// Production applications must use a verified identity from authentication.
function getTenant(req) {
  const tenantId = req.header("x-demo-tenant-id");
  return tenants.get(tenantId);
}

// Keep tenant-specific resource access in one reusable function.
function getTenantProjects(tenantId) {
  return projects.filter((project) => project.tenantId === tenantId);
}

// Demonstrate how subscription rules can be expressed as business logic.
function canCreateProject(tenant) {
  const plan = plans[tenant.plan];

  if (!plan) {
    throw new Error("Tenant has an unsupported subscription plan");
  }

  const currentProjects = getTenantProjects(tenant.id);
  return currentProjects.length < plan.maxProjects;
}

// Middleware ensures every demo endpoint identifies a known tenant.
function requireTenant(req, res, next) {
  const tenant = getTenant(req);

  if (!tenant) {
    return res.status(401).json({
      error: "A valid demo tenant is required",
    });
  }

  req.tenant = tenant;
  next();
}

// Health checks help deployment platforms verify that the API is running.
app.get("/health", (req, res) => {
  console.log("Step 1: Health check received");
  res.json({ status: "ok", service: "saas-demo-api" });
});

// Return the authenticated tenant's own account and subscription details.
app.get("/api/account", requireTenant, (req, res) => {
  console.log(`Step 2: Loading account for ${req.tenant.id}`);

  res.json({
    id: req.tenant.id,
    name: req.tenant.name,
    plan: plans[req.tenant.plan].name,
    projectLimit: plans[req.tenant.plan].maxProjects,
    projectCount: getTenantProjects(req.tenant.id).length,
  });
});

// List only projects owned by the current tenant.
app.get("/api/projects", requireTenant, (req, res) => {
  console.log(`Step 3: Listing projects for ${req.tenant.id}`);

  res.json({
    data: getTenantProjects(req.tenant.id),
    total: getTenantProjects(req.tenant.id).length,
  });
});

// Create a project after validating input and checking subscription limits.
app.post("/api/projects", requireTenant, (req, res, next) => {
  try {
    console.log("Step 4: Validating project creation request");

    const name = typeof req.body.name === "string"
      ? req.body.name.trim()
      : "";

    if (!name || name.length > 100) {
      return res.status(400).json({
        error: "Project name must contain 1 to 100 characters",
      });
    }

    console.log("Step 5: Checking the subscription project limit");

    if (!canCreateProject(req.tenant)) {
      return res.status(403).json({
        error: "Project limit reached for your subscription",
        plan: req.tenant.plan,
        limit: plans[req.tenant.plan].maxProjects,
        upgradeRequired: true,
      });
    }

    // Assign ownership on the server, never from the request body.
    const project = {
      id: String(nextProjectId++),
      tenantId: req.tenant.id,
      name,
      createdAt: new Date().toISOString(),
    };

    projects.push(project);

    console.log(`Step 6: Project created with ID ${project.id}`);
    return res.status(201).json({ data: project });
  } catch (error) {
    next(error);
  }
});

// Return a consistent response for endpoints that do not exist.
app.use((req, res) => {
  res.status(404).json({ error: "Endpoint not found" });
});

// Centralized error handling prevents accidental exposure of internal details.
app.use((error, req, res, next) => {
  console.error("Unexpected API error:", error.message);
  res.status(500).json({ error: "Internal server error" });
});

// Start the API only when this file is executed directly.
if (require.main === module) {
  app.listen(PORT, () => {
    console.log(`SaaS demo API listening on http://localhost:${PORT}`);
    console.log("Use x-demo-tenant-id: tenant-acme or tenant-nova for demo requests");
  });
}

// Export the application so automated tests can import it without listening.
module.exports = { app, plans, tenants, projects, canCreateProject };
Enter fullscreen mode Exit fullscreen mode

Conclusion

JavaScript provides a practical foundation for SaaS development, allowing teams to build APIs, frontend experiences, and background workflows with a shared ecosystem. By organizing an application around reusable business logic, tenant-aware data access, and consistent error handling, developers can create a codebase that is easier to maintain as customer requirements grow.

The example demonstrates several important foundations: server-side input validation, subscription-based usage limits, tenant-specific project access, and predictable HTTP responses. Before deploying a real product, add authentication, role-based authorization, persistent storage, database transactions, rate limiting, request logging, automated tests, and a secure billing integration. In particular, replace the demonstration tenant header with an authenticated identity and enforce tenant ownership in every relevant database query.

Start with a focused product and a clear set of customer needs rather than building every possible feature at once. Once the core workflow is reliable, introduce monitoring, background jobs, billing automation, and analytics based on actual usage. Which part of SaaS development are you focusing on right now: architecture, authentication, subscription billing, or scaling?

Top comments (0)