Build a Single-Tenant SaaS App with Node.js

Kyle Gawley
Kyle Gawley
Gravity founder
Build a Single-Tenant SaaS App with Node.js

Confused about what multi-tenant and single-tenant SaaS means? Even more confused about how to implement this in your application?

Don't worry - you're not alone; this is a confusing topic.

In this tutorial, we'll look at how to build a single-tenant SaaS architecture in Node.js with an Express router.

There are lots of ways to achieve this and for the purpose of simplicity, I'm going to keep the example basic, but I will provide some suggestions for alternative ways to optimise the code.

Before we get into that though, it's essential to decide which architecture is the best choice for your project, because both single-tenant and multi-tenant structures have significant strengths and weaknesses.

Single-tenant vs Multi-tenant SaaS - What's The Difference?

One of the primary benefits of SaaS is that one instance of an application can serve thousands of customers - keeping costs low and ensuring ease of maintenance and updates.

This type of architecture is called multi-tenant because multiple tenants share the same infrastructure.

On the other hand, a single-tenant architecture provides each tenant with its own database and/or application instance.

Each structure provides it's own set of benefits and drawbacks - it's essential to consider these thoroughly before setting up your application, especially a single-tenant model because this can have huge implications on the cost and ease of maintenance within your application.

Multi-Tenant SaaS Advantages

Low Running Costs

You can spin up a single application instance and database and serve thousands of customers from as little as $5/mo.

Easy Maintenance and Upgrades

Shipping updates to your application is simple because you only have one code base and one database to manage.

Multi-Tenant SaaS Disadvantages

Limited Customisation

Customers must share the same functionality and there is limited scope for bespoke features and customisation.

Security

All users share the same database and while professional coding practices will ensure there is no bleeding of data across accounts, if the database is breached, then an attacker could gain access to every user's data. This is not to say that a sophisticated attacker would not single out a specific database after gaining access to your system.

Single-Tenant SaaS Advantages

Customisation

With each user having their own database and instance of the application, providing bespoke features and customisation without affecting other users is much easier.

Security

Arguably, isolating data in separate databases provides a more secure environment, but this shouldn't be used as a bandaid for bad coding or security. Some larger companies will require that their data is isolated (or even self-hosted) before using your service.

Single-Tenant SaaS Disadvantages

Cost

Cost implications of single-tenant SaaS are huge, and the costs can escalate rapidly. One $5/mo multi-tenant database serving 1,000 users suddenly becomes $5,000/mo on single-tenant architecture.

Maintenance

Shipping updates is not straightforward because changes will need to be applied to potentially thousands of databases and even instances of bespoke code. This can become costly and very time-consuming with vast amounts of time required for testing.

Should You Use a Single-Tenant SaaS Architecture?

In most cases, I would advise against it unless you have very specific requirements for data isolation along with the finances, resources and time to support it. A single-tenant architecture adds overhead and complexity that is not required in most circumstances.

If you're concerned about security, then good coding and security practices can eliminate most of the risk.

If you still want to go ahead and create a single-tenant application; let's look at how this is constructed in Node.js.

1. Single-Tenant SaaS Application Structure

A single-tenant app isn't massively different to multi-tenant application. You will need one central database for storing and authenticating users; this will look something like this:

  • id
  • first_name
  • last_name
  • email
  • last_active
  • connection

Note the last field: connection - this will be used for storing the user-specific database connection string. You can also store these as individual fields, and you'll want to encrypt it, but for simplicity, we won't use encryption in this tutorial.

In addition to the central authentication database, you'll then have a schema for the individual user databases designed to suit your own project needs.

2. Creating Users

When a new user signs up to your application, you'll need to create a new database on the fly and store the connection string in your global user table. To do this, you will need to use a database service that has an API for creating and deleting databases.

Either Amazon RDS or Digital Ocean will work for this; I prefer Digital Ocean because the API is simple to use and creating a new database is very easy. Use my referral link to get $100 of free credit.

3. Handling API Requests

Once a user has an account and database, they can interact with your application.

In a multi-tenant Node application - this is relatively simple to achieve by storing the user_id in a JSON web token and passing it with each API call.

With the single-tenant architecture, we'll need to add another step in the chain to connect to the correct database.

The simplest way to handle this is with a custom middleware function on the API routes.

api.post('/api/content', auth.verify('user'), auth.db(), use(contentController.create));

In the line above, I've inserted two middleware methods:

auth.verify checks the user permission level and sets req.user to the current user ID (we'll use this in the next step).

auth.db creates a database object for this user.

If you're wondering what the use method is - check out the previous article on handling errors in a Node.js application.

Let's take a look at the second method in more detail.

exports.db = function(){
  return async function(req, res, next){
    try {

      // get user account
      const userData = await user.get(req.user);

      if (!userData)
        return res.status(401).send();

      // create the db object
      // recommended: en/decrypt the connection string
      req.db = require('./knex')({ 
          
          client: 'mysql', 
          connection: userData.connection 
      
      });
        
      // performance tip: if you're not stateless 
      // then store the database in a session to avoid
      // making the call to user model each time
        
      next();

    }
    catch (err){

      console.error(err);

    }
  }
}

Here we utilise the user ID stored in req.user to get the user account details, which includes the connection string for this user. As I mentioned previously, you'll want to encrypt this when the user is created and decrypt it in the example above.

Next, we require the Knex module and pass in the connection string and assign this to the request object before calling Express's next handler and move onto the next handler in the chain (our controller).

In the next step, we'll be able to access the req.db object to make the database call. You could also save the db object in a session to prevent having to make the call to the user model each time if you're not dependant a stateless configuration.

In the controller method that handles the API request, you can now call the relevant model and access the db object:

exports.create = async function(req, res){

  const data = await content.create(req.db, req.body, req.account);
  res.status(200).send({ message: 'Content created', data: data });

}

The content.create model then looks like this:

exports.create = async function(db, data, account){

  data.id = uuidv4();
  data.account_id = account;
  await db('content').insert(data);
  return data;

}

Here we utilise the req.db object (passed as db) to perform the database query, which will insert the data into the user-specific database.

Isolating User Data

If you're building a single-tenant setup and want to keep it as simple as possible; I recommend keeping the user data in a global table and isolating the rest of the data in a user-specific database like this example.

You can also, of course, store the users in the isolated database, but this will add an extra layer of complexity to your application.

That's an App, Folks

This example is a simple introduction into building single-tenant SaaS application. Your needs may vary depending on the performance and isolation requirements of your application. You may want to setup database connections for every use when the server is initialised (providing you don't have a lot of users).

Take a SaaS Shortcut

If you want to get a head start, the Gravity SaaS boilerplate comes with most of this functionality (plus loads more) included and can be customised for single-tenant use on request.

Download The Free SaaS Boilerplate

Build a full-stack web application with React, Tailwind and Node.js.