Creating a multitenant SaaS service with Turso, Remix, and Drizzle

Building a multitenant SaaS website where each user gets their own Turso database by leveraging Turso's numerous database offering.

Cover image for Creating a multitenant SaaS service with Turso, Remix, and Drizzle

During its launch week, Turso announced that it is radically increasing the database offering to its users, 500 databases for the free tier and 10,000 databases for the scaler plan. What would be the best way to make use of all of these databases other than building a multi-tenant Software as a service (SaaS) system, in the process reducing query response times for each tenant.

In this blog post, we're going to learn how to create a Customer Relationship Management (CRM) service using Remix, Turso, and Drizzle.

Turso is the distributed database based on libSQL, the open-contribution fork of SQLite.

Remix is a full stack web framework that lets you focus on the user interface and work back through web standards and deliver fast, slick, and resilient user experiences.

Drizzle is a TypeScript Object-Relational Mapping tool (ORM) with support for PostgreSQL, MySQL, and SQLite databases.

The CRM service that we'll be building will first let organizations create accounts and onboard agents, secondly it will let agents manage tickets opened by customers, and will finally let customers open tickets and communicate with organizations.

Each organization in the CRM service we're building will have its own database that comprises of the following four tables:

  • agents: Stores records of organization agents
  • tickets: Stores records of tickets opened by customers
  • conversations: Stores records of conversations between organization agents and customers
  • messages: Stores records of conversation messages

(The links in the above table names redirect to the respective table schema)

The records of all organizations in the CRM service together with their individual user and database access credentials will all be kept in a single “organizations” table for simplicity.

The tenants in this scenario are the organizations that are using the CRM service to communicate with their customers. For the rest of this tutorial the two terms will be used interchangeably.

TLDR; The complete source code to the project that we are building can be found of GitHub.

Let's get to building.

Pre-requisites:

The prerequisites for this tutorial are:

#Setting up the Remix Project

Create a new Remix project by running:

npx create-remix@latest turso-crm

Afterwards, cd into the project's directory and run npm install to install the project's dependencies.

The final version of the website we're building uses TailwindCSS, set it up by following the instructions provided in the Remix docs.

The CRM service website is going to have the following pages.

The pages listed above link to their respective Remix route files as found on GitHub.

Use the file names found in the GitHub repository in the links above to create route files for the Remix app, place the route files inside the “app/routes” directory.

Per the route file naming shown above, the organization and agent routes use an _agent.tsx and _org.tsx root route files respectively.

In the website, we are using a cookie-based session authentication system as provided by Remix's createCookieSessionStorage helper function.

#Setting up Drizzle

Since we'll be using Drizzle to build and issue queries within the site's pages, let's set it up by installing its dependencies.

Run the following command to install the Drizzle dependencies.

npm install drizzle-orm @libsql/client
npm install -D drizzle-kit tsx

Let's then create two schema files. For the CRM service database we'll have the following Drizzle schema placed inside “drizzle/schema.ts”.

// drizzle/schema.ts

import { sql } from 'drizzle-orm';
import {
  index,
  integer,
  sqliteTable,
  text,
  uniqueIndex,
} from 'drizzle-orm/sqlite-core';

export const organizations = sqliteTable(
  'organizations',
  {
    id: text('id').primaryKey(),
    name: text('name').notNull(),
    website: text('website').notNull(),
    username: text('username').notNull(),
    email: text('email').notNull(),
    password: text('password').notNull(),
    logo: text('logo'),
    dbUrl: text('db_url'),
    createdAt: integer('created_at').default(sql`(cast(unixepoch() as int))`),
    updatedAt: integer('updated_at').default(sql`(cast(unixepoch() as int))`),
  },
  (authors) => ({
    emailIdx: uniqueIndex('email_idx').on(authors.email),
    usernameIdx: uniqueIndex('username_idx').on(authors.username),
    nameIdx: index('name_idx').on(authors.name),
  }),
);

And, for the tenant databases, we'll have this Drizzle schema which we'll place inside the “org-schemas.ts“ file under the “drizzle” directory.

Next, add the following package.json scripts.

"drizzle:generate": "drizzle-kit generate:sqlite --out ./drizzle/migrations --breakpoints --schema=./drizzle/schema.ts",
"drizzle:generate:orgs": "drizzle-kit generate:sqlite --out ./drizzle/migrations-orgs --breakpoints --schema=./drizzle/org-schema.ts",

With these two scripts set up, generate the SQLite schemas which we'll later migrate to our databases by running:

npm run drizzle:generate
npm run drizzle:generate:orgs

You should see the generated SQLite files under “/drizzle/migrations” and “/drizzle/migrations-orgs” directories respectively.

#Setting up Turso

With Drizzle set up, it's time to set up Turso.

Let's start off by creating the database for the CRM service that will keep records of all the tenants, including their database and authentication information.

turso db create turso-crm

To work with multiple databases, such as in the scenario of the service we're building, we need a way to authenticate queries to all the databases from a single authentication token. We can create such a token for the CRM service by running the following command.

turso db tokens create turso-crm --group

Next, get the URL of the CRM service database.

# database URL
turso db show --url turso-crm

Afterwards, store the obtained values as environment variables inside a .env file at the root of the project.

TURSO_URL=<OBTAINED_URL>
TURSO_AUTH_TOKEN=<CREATED_TOKEN>

Set up two database client instance initiating functions which will help us construct and issue Drizzle queries to Turso, one for the CRM service and the other for the tenants organization databases.

For the CRM service database, create a “client.ts” file under “app/lib” and add the following code.

// app/lib/client.ts

import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/http';
import * as schema from '../../drizzle/schema';

interface Env {
  TURSO_DB_AUTH_TOKEN?: string;
  TURSO_DB_URL?: string;
}

export function buildDbClient() {
  const url = (process.env as unknown as Env).TURSO_DB_URL?.trim();
  if (url === undefined) {
    throw new Error('TURSO_DB_URL is not defined');
  }

  const authToken = (process.env as unknown as Env).TURSO_DB_AUTH_TOKEN?.trim();
  if (authToken === undefined) {
    if (!url.includes('file:')) {
      throw new Error('TURSO_DB_AUTH_TOKEN is not defined');
    }
  }

  return drizzle(createClient({ url, authToken }), { schema });
}

And, for the tenant databases, create a “client-org.ts” file under the same directory and in it add the following code.

// app/lib/client-org.ts

import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client/http';
import * as schema from '../../drizzle/org-schema';

interface Env {
  url: string;
  TURSO_DB_AUTH_TOKEN?: string;
}

export function buildDbClient({ url }: Env) {
  if (url === undefined) {
    throw new Error('db url is not defined');
  }

  const authToken = (process.env as unknown as Env).TURSO_DB_AUTH_TOKEN?.trim();
  if (authToken === undefined) {
    throw new Error('TURSO_DB_AUTH_TOKEN is not defined');
  }

  return drizzle(createClient({ url: `libsql://${url}`, authToken }), {
    schema,
  });
}

As you can see, the database client initiating function for the CRM service is using the environment variables that we generated earlier, while that of the individual tenants requires the database URL as an argument passed to it.

We've set the tenant function this way since at build time we do not know the database URLs of the tenant databases. The databases will first need to be created for us to obtain their URLs.

We'll have a look at this in the next section.

Before proceeding, let's first migrate the schema of the CRM service database.

Create a “migrate.ts” file inside the “drizzle” directory and inside it add the following code.

// drizzle/migrate.ts

import 'dotenv/config';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { drizzle } from 'drizzle-orm/libsql';
import { createClient } from '@libsql/client';

export const client = createClient({
  url: process.env.TURSO_DB_URL as string,
  authToken: process.env.TURSO_DB_AUTH_TOKEN as string,
});

export const db = drizzle(client);

async function main() {
  try {
    await migrate(db, {
      migrationsFolder: 'drizzle/migrations',
    });
    console.log('Tables migrated!');
    process.exit(0);
  } catch (error) {
    console.error('Error performing migration: ', error);
    process.exit(1);
  }
}

main();

Then, add the following package.json script.

"drizzle:migrate": "tsx drizzle/migrate"

We can then perform the database migration by running npm run drizzle:migrate.

Verify the migration by checking if the “organizations” table was added to the database.

turso db shell turso-crm ".tables"

You should see the “organizations” table listed among the results.

#Creating Turso databases for individual tenants

In the CRM service, every tenant that registers for the service gets their own database.

To maintain some type of order, each ternant's organization database name will be constructed by combining the name of the CRM service database followed by the username of the registered tenant's account, the two being separated by a dash. E.g for a tenant with the username “foo” the database name will be “turso-crm-foo”. To ensure this naming convention, all organization usernames are limited to alphanumeric characters without the underscore.

To create databases on the fly (without using the Turso CLI) we need to make use of the Turso REST API.

To use the Turso REST API we'll first need to create a platform API token that needs to be passed in the “Authorization“ header for all API requests, this is because the Turso REST API doesn't permit unauthenticated requests.

Run the following command to mint a Turso platform API token.

turso auth api-tokens mint turso-crm

Store the obtained value inside the .env file, assigning it to the TURSO_API_TOKEN key.

TURSO_API_TOKEN=<OBTAINED-API-TOKEN>

Let's set up a few more environment variables which will help us with the next steps of this tutorial. (Project's environment variables template can be found in this file)

APP_NAME=turso-crm
TURSO_API_URL=https://api.turso.tech
APP_ORGANIZATION=<YOUR_GITHUB_USERNAME>
SESSION_SECRET=B4428F73-67C3-4285-8ADD-28CC93B2F79D
APP_PRIMARY_LOCATION=sjc # can be any valid location
APP_GROUP=default # use custom group name if created one for the service in the scaler and entereprize plans

Here's an explanation for the above environment variables:

  • APP_NAME: This is the name of the application we're creating, same as the name of the CRM service database. This will be used to prefix all created tenant databases.
  • TURSO_API_URL: This is the base URL to the Turso REST API
  • APP_ORGANIZATION: This is the slug name of your organization on Turso. Assign the slug name of a specific organization that you've created or you can use your GitHub username since Turso sets that up as your default organization name.
  • SESSION_SECRET: This is the secret used to sign/unsign the value of cookies used in the Remix website's authentication.
  • APP_PRIMARY_LOCATION: The Turso location id to the primary location of your Turso databases. Get the Turso supported locations by running turso db locations.
  • APP_GROUP: The group that the multitenant databases will belong to. (“default” is the default group name for the starter plan)

Inside the CRM service website, we are creating new databases for tenant organizations after a user has successfully registered for a new organization account.

This is done inside the createOrganizationDatabase() utility function.

In the createOrganizationDatabase() function, we are using the Turso REST API to create a database for a registered tenant organization, placing it inside a specific group (“default” for the starter plan) and primary location. Finally, we migrate the database with the tenant database schema we created earlier.

The database URL of the created database is stored as part of the organization's registration details.

#Issuing commands inside the website pages

Beyond leveraging the Turso REST API to create databases, inside the Remix app pages we are generally issuing Drizzle queries to our Turso databases.

#Organization dashboard

In the organization dashboard page loader function, we are fetching all the tickets and agents that are found inside the organization database and passing them to the page component to be displayed.

The getOrganizationDetails() function in the page's loader function fetches the organization details from the CRM service's database (turso-crm) “organizations” table by using the organization ID obtained from the authentication cookie.

This page also lets the authenticated tenant onboard new agents to the organization by submitting the agent information to the action function found within the “app/routes/org-action.tsx” route endpoint.

From the dashboard you can also peek into the interactions between your agents and customers.

Here's the preview of the organization dashboard page.

The dashboard page
The dashboard page

#Agent dashboard page

In the agent's dashboard page loader function, we are fetching the open tickets belonging to an organization and the agent's conversations then passing them to the page component to be displayed.

The getAgentDetails() function in the agent's dashboard page loader function fetches the agent's details from the organization's “agents” table by using the agent ID obtained from the authentication cookie.

In this page, the agent can engage with open customer tickets and in doing so start new conversations.

Here's a preview of the agent's dashboard page

The agent's dashboard
The agent's dashboard

#Agent conversation page

When an agent engage's a new ticket, they are taken to the conversation page. The conversation page's loader function fetches the details of a conversation and passes this information to the page component.

In the conversation page, the agent can chat with customers so as to get to the bottom of issues that customers are facing.

After having resolved a customer's issue, the agent can opt to close the ticket by clicking the “Close Ticket” button.

Here is the preview of the agent's conversation page.

The agent's conversation page
The agent's conversation page

#Customer open ticket page

Customers can open issue tickets in organizations by visiting the links to the organizations as found on the CRM service website's landing page.

Inside the ticket opening page, they'll find a form that will let them fill in their information, document their issues, and open tickets within organizations.

In the loader function of this page, we are fetching an organization's details based on the organization's username provided as part of the page's path parameters. We then pass the organization information to the page component to be used to display the organization's information inside the page viewed by the user.

Here's is the preview of the customer's ticket opening form

Customer's ticket opening form
Customer's ticket opening form

#Customer conversation page

Similar to the agent's conversation page, the customer conversation page's loader function fetches the details of a conversation and passes this Information to its page component.

One possible implementation that could be set for the customer to receive the link to the conversation page (not demonstrated in this example) would be email integration, whereby an email can be sent to the customer once an agent engages with their opened ticket.

Here's a preview of the customer's conversation page.

Customer's conversation page
Customer's conversation page

#Summary

To summarize, we've built a CRM SaaS that assigns every registered tenant their own database while having a single database that keeps records of all the registered tenants and in turn their individual databases.

We've learned how to create a new Remix project, add pages, fetch, and display data to users.

We've set up and used Drizzle to create database schemas and build queries that we use to transact data to and fro the Turso databases within our website's pages.

We've also learned how to create Turso platform API tokens and used them to send authenticated requests to the Turso REST API to create new databases.

You can find the source code to the CRM SaaS we've built in this GitHub repository.

This brings us to the end of this tutorial that demonstrates one of the many possible use cases for leveraging Turso's numerous database offering, in this case the building of a CRM SaaS service.

Visit the following links to learn more about the tech stack used in this tutorial.

scarf