There are tons of resources on the Internet for making a SaaS data model, but very few use NoSQL or Firebase Firestore.
Firestore is fairly simple to use, but structuring it well requires analysis from a different point of view compared to other databases:
- securing the data using Firestore Security Rules
- optimizing queries and writes to save on billing costs (remember, we pay for reads/writes and the data transferred)
- due to the querying limitations in Firestore, ensuring that the data-model allows our queries
Developing an architecture without taking into account the points above can result in expensive and inefficient decisions.
We developed MakerKit with simplicity in mind, in a way such that you can effortlessly adapt the data model we created to your application's domain.
In this post, we explore the data model we at MakerKit adopted for our own SaaS boilerplate. Feel free to comment, take it and extend it as you wish!
Overview of a simple SaaS data model
Every SaaS has a different data model, but we can identify certain entities that will be pretty common across most apps.
These are:
- Users/Accounts
- Groups of users, such as organizations/teams/projects
- Invites a member to join a group
- A plan/membership the group subscribed to
The above is the core part of a SaaS and what any SaaS boilerplate should provide by default. However, getting it right for every use case and scenario is impossible: hence, MakerKit's model is exhaustive yet straightforward.
Users and Accounts
Users/Accounts are the primary and most basic entities of most applications. Thanks to Firebase, the aspect of authenticating accounts is separate from the users' data, which helps simplify our job.
Whenever a user creates a new account for your application using Firebase Authentication, this will be added to the Auth Database, but not to Firestore.
So, how can we make sure that a user also has its relative record in Firestore? There are a couple of ways.
Creating user records in Firestore
First, what you should know is that the most common way of creating user records in Firestore is to create a users
collection.
We can set as the ID of the user record the same ID created by Firebase Authentication, so we can keep them easily in sync and make sure it's unique across the collection.
Let's assume the ID created by Firebase is 423631
, then we will create a record in Firestore at the path: /users/423631
.
Creating a record after sign-up
The simplest way is to create a Firestore record right after sign-up. It's also not the most straightforward when you take into account that the transaction isn't atomic and any errors can happen between the sign-up and the creation of the record, which may result in a poor UX and an out-of-sync DB.
Creating a record as part of an Onboarding Wizard
The below is my favorite way, especially for collecting valuable data for and about your users.
After sign-up, your route guard checks that the user is onboarded or that the user record exists. You can do this using a token's custom metadata,
for example.
If not, redirect to the onboarding wizard, which will take care of creating a user record and its relative organization.
The above is the approach taken by MakerKit.
Organizations
Organizations, or groups of users, contain information about their members and their role within the organization.
In MakerKit, organizations have a property named members,
which is a map containing information about the member. We use an object to simplify writing security rules.
The following is the interface of an organization:
type UserId = string; // this is the ID assigned by Firebase Authinterface Organization { name: string; members: Record<UserId, FirestoreOrganizationMembership>; timezone?: string; logoURL?: string | null; subscription?: OrganizationSubscription; customerId?: string;}
Let's focus on the organization members for the time being.
Each member has the following interface:
interface Member { role: MembershipRole; user: UserRef;}
As you can see, we stored two properties:
role
is an enum that defines the user's role. This is very simple and doesn't allow for granular permissions, which you may needsuser
is a Firestorereference
to the user record for simplifying access to the user record. It can also be an ID.
Querying Organizations and Members
How does the data structure above allow us to fulfill the following use-cases?
- Fetch all the organizations the current user belongs to
- Fetch all the members of the selected organization
- Make sure our security rules can allow us to enforce permissions and allow for efficient querying
Fetching data for a specific organization
The user's current organization is passed in via a cookie when the page gets rendered using SSR. By selecting the first organization, the cookie will hold the value of the current organization ID selected for the user.
We will use this cookie to fetch the data of the current organization. To do so, we can query the path /organizations/{id}
, using the following hook:
function useFetchOrganization(organizationId: string) { const firestore = useFirestore(); const path = `/organizations`; const ref = doc( firestore, path, organizationId ); return useFirestoreDocData(ref);}
Security Rules to read an Organization
How can we structure our Firestore Security Rules to allow this query?
We can check if the authed user ID is within the members map
of the organization.
The function below allow us to read the current user ID:
function userId() { return request.auth.uid;}
We can now check if the ID exists in the members' map
:
match /organizations/{organizationId} { allow read: if userId() in existingData().members;}
Fetching a user's organizations
To list all the organizations a user belongs to, we can check if the user ID appears to be within the members
property of the organization.
The hook below will allow us to list the user's organizations:
export function useFetchUserOrganizations(userId: string) { const firestore = useFirestore(); const path = `organizations`; const organizationsCollection = collection( firestore, path ); const userPath = `members.${userId}`; const operator = '!='; const constraint = where(userPath, operator, null); const organizationsQuery = query( organizationsCollection, constraint ); return useFirestoreCollectionData(organizationsQuery);}
Invites
We store invites
to join an organization as a collection under each organization, such as organizations/1/invites/1
.
Because we may need to access an invitation without knowing the organization ID, we have two ways to go:
- do not store invitations as a
subcollection
, but as a top-level collection - create an index using the Firestore Console, which allows us to query deep collection using the
collectionGroup
method. At MakerKit, we decided to go with the second way
Defining the Invite's data model
Below is the interface of an Invite
:
interface MembershipInvite { email: string; role: MembershipRole; code: string; expiresAt: number; organization: { id: string; name: string; };}
We stored the email of the user invited, which can also be helpful if you want to restrict the invited user's email to the same one provided by the inviter.
Additionally, it's essential to store the role assigned by the invitee, the the time when we consider the invite expired, and a unique code code
which we can use to expose and retrieve the invite.
We also add (duplicate) some information about the organization to embed it into the invitation email. Adding the organization saves one more read each time we query the invite.
The invite gets sent with a URL similar to the following: /auth/invite/{code}
. We know which invite we need to process by adding the code to the URL.
After the invite is processed, it gets deleted from the DB.
Security Rules for Invites
To securely list the invites to join an organization, we need to check that the member reading the invites belongs to it:
match /organizations/{organizationId} { match /invites/{inviteId} { allow list: if userIsMemberByOrganizationId(organizationId); }}
As you may have noticed above, we can access the organization ID, which also allows us to query the organization document.
The functions below help us evaluate if, given an organization ID, the current user belongs to it.
function userIsMemberByOrganizationId(organizationId) { return userIsMember( getOrganization(organizationId) );}function getOrganization(organization) { return get(/databases/$(database)/documents/organizations/$(organization));}function userIsMember(organization) { let role = getUserRole(organization, userId()); return role >= 0;}function getUserRole(organization, userId) { let members = organization.data.members; let member = members[userId]; return member != null ? member.role : -1;}
Yes, it looks very similar, but the code above is not Javascript!
Payments and Subscriptions
Without payments, your SaaS won't get paid. It's needless to say that any SaaS will eventually need a payment system.
The most popular for startups is probably Stripe, but it's also common having to integrate different billing systems for hosting your SaaS on marketplaces: for instance, you may have to get paid via Apple if you host your app on iOS, or using Shopify Billing if you allow shops to install the app via the Shopify Marketplace.
In practice, it means we will need a model generic enough to adapt to multiple payments processors.
Subscription Model
The model below is added to an Organization
when creating a plan. We consider it an optional field because creating a plan may or may not happen.
type PaymentProcessor = string;enum OrganizationPlanStatus { AwaitingPayment = 'awaitingPayment', Paid = 'paid',}interface OrganizationSubscription { id: string; planId: string; // priceId in Stripe processor: PaymentProcessor; status: OrganizationPlanStatus; currency: string | null; interval: string | null; intervalCount: number | null; createdAt: UnixTimestamp; periodStartsAt: UnixTimestamp; periodEndsAt: UnixTimestamp; trialStartsAt: UnixTimestamp | null; trialEndsAt: UnixTimestamp | null;}
The above is the bare minimum amount of data you may want to store; depending on your needs, you may want to collect more information from the payments processors' webhooks.
Security Rules for Subscriptions
A membership is probably not something you want clients able to modify, no matter which role the user has.
To avoid any issues, we will need to write a security rule that disallows updating the subscription
fields of an organization.
match /organizations/{organizationId} { allow update: if canUpdateOrganization(organizationId);}function canUpdateOrganization(organizationId) { let isMember = userIsMemberByOrganizationId(organizationId); let noDisallowedFields = fieldsNotInUpdateAction(['members', 'subscription']); return isMember && noDisallowedFields;}
As an alternative to this, you can also store subscriptions in their collection. The only downside to this is that it takes one additional read to grab the subscriptions' data.
Final Words
Time to wrap this post. The above is an introduction to how we modeled our Firestore data structure to support the basics needs of any SaaS application, while keeping it open for change; as it will inevitably happen, there will be lots of changes.
I hope you've found this helpful post. Consider contacting us if you need any help with starting to build your SaaS application with Next.js and Firebase. We're here to help.