Multi-Tenant Next.js App with Prisma and PropelAuth
Multi-tenant applications
In the software world, a tenant is a collection of users that use a product together. This could be as few as a single user or as many as a large enterprise.
Single-tenancy is when each tenant gets their own dedicated application, database, and infrastructure. This contrasts with multi-tenancy where tenants share resources.
In either case, a common goal is to isolate tenants from one another so they cannot view or modify each other’s data. In the single tenant model, this happens naturally because they are completely separated. In the multi-tenant model, this has to be enforced at either the application or database level.
Why choose a multi-tenant application even with this extra bit of work? There are some obvious advantages, like not needing to maintain or pay for separate infrastructure for each customer. In this post, we’ll show how to build a multi-tenant application using Next.js, Prisma, and PropelAuth.
What are we going to build?
We’ll build a simple B2B application where each user can make "posts" within a tenant. All users in a tenant should be able to read their own posts, and no one outside the tenant should be able to view or access them.
In our B2B product, our tenants are Organizations, which we’ll see more on later. The final product will look like this:
Where everyone in a tenant can view and make posts that only they can see.
How do we deal with users in multiple tenants?
If a user is only in one tenant, it’s pretty obvious which tenant’s data we should show. But what happens when users are in more than one tenant?
In practice, this problem comes up often. GitHub allows users to be in multiple organizations at once. Similarly, you can be in as many Slack workspaces as you want, and you can have multiple Google/Twitter accounts that you toggle between.
How you approach this problem depends on your application but there’s a couple schools of thought:
- Each tenant should have its own unique URL (e.g. https://tenant.example.com or https://example.com/tenant/)
- There’s only one url (e.g. https://app.example.com) and the user selects which tenant to view in the UI with a dropdown menu or something similar.
For more details on which approach you choose, we've written about the pros and cons of each here. In this example, we’ll assume that users can view their tenant’s posts by going to a URL where their tenant’s name is in the path, like https://example.com/tenant/
Checking for a path parameter in Next.js
First, we’ll create a React hook that can tell us which tenant the user is currently viewing by inspecting the URL’s path. Start by creating a new Next.js application
yarn create next-app --typescript
Next.js has support for dynamic routes that make this pretty straightforward. A file created at pages/org/[orgName]/posts.js will respond to any route like /org/something/posts or /org/somethingelse/posts.
If we write the following code in that file:
import {NextPage} from "next";
import {useRouter} from 'next/router'
const Posts: NextPage = () => {
const router = useRouter()
const {orgName} = router.query
return <div>You are viewing {orgName}</div>
}
export default Posts
We’ll be able to see which tenant the user is attempting to view, based on their URL
Verifying the user is in a given tenant
We don’t currently have a concept of users, let alone tenants. We wouldn’t want just anyone to be able to view all the posts for the “123” organization, or any organization for that matter.
This is where PropelAuth comes in. PropelAuth is an auth service designed for multi-tenant and B2B use cases. It includes self-service UIs for each tenant/organization to manage themselves. Our users will be able to sign up, create tenants, and invite their co-workers, without us writing any code for it.
The Getting Started Guide will walk you through how to configure your user’s auth experience, from the look and feel of the UI to the login options you give to your users to the organization options for those same users. This demo shows some of the configuration options available.
After configuring, our users can now sign up, log in, create tenants, etc - on their own. All we have to do is check to see if they are in the tenant they are trying to access. We can do this with PropelAuth’s React library.
yarn add @propelauth/react
Then, in pages/_app.js, we wrap our application with an AuthProvider. The AuthProvider reaches out to our PropelAuth instance and fetches our current user’s metadata, if they are logged in. You’ll need your authUrl which you can find in your dashboard under Frontend Integration.
import {AuthProvider} from "@propelauth/react";
function MyApp({ Component, pageProps }: AppProps) {
return <AuthProvider authUrl={process.env.NEXT_PUBLIC_PROPELAUTH_AUTH_URL}>
<Component {...pageProps} />
</AuthProvider>
}
And that’s it, we can now access our user’s information anywhere in our application. We can either use higher order functions like withAuthInfo or hooks like useAuthInfo. Let’s update our /org/[orgName]/posts.js file to check if the user is in orgName.
// This can be re-used in any route that has an orgName parameter
const useOrgMembershipByPathParam = () => {
const router = useRouter()
const {orgName} = router.query
// gets authentication info for the current user
const authInfo = useAuthInfo()
// Loading only happens on initial page load
if (authInfo.loading) {
return {status: "loading"}
} else if (typeof orgName !== "string") {
// This represents a malformed orgName
return {status: "user-not-in-org"}
}
// orgHelper makes it easy to perform common information about orgs for our
// current user, like checking if they are in an org by its name/ID
// Users that are not logged in will return null as well
const org = authInfo.orgHelper?.getOrgByName(orgName);
if (org) {
// This is the case where the user is in the organization, we'll return the org and an access token
return {
status: "user-in-org",
org,
accessToken: authInfo.accessToken,
}
} else {
return {status: "user-not-in-org"}
}
}
const Posts: NextPage = () => {
const orgMembership = useOrgMembershipByPathParam();
if (orgMembership.status === "loading") return <div>Loading...</div>
else if (orgMembership.status === "user-not-in-org") return <div>Not found</div>
return <div>You are viewing {orgMembership.org.orgName}</div>
}
For any tenants / organizations our users are not a member of, they will get a “Not found” error.
Note that this is just a frontend check. Frontend checks are important for usability but not security. If we fetch all posts for a tenant and then decide not to show it on the frontend, any user can just check their network traffic to see the data.
We still do want to check this so that a user that happens upon the wrong page is given a reasonable error message, but now let’s look at how we can make authenticated requests to our backend.
Making authenticated requests to our backend
In order to make an authenticated request, we need to provide our backend with some verifiable piece of information that proves our user is who they say they are. PropelAuth provides access tokens for this, which our backend can verify without making any external requests. We already pass back an access token for our user from useOrgMembershipByPathParam so let’s use it.
const Posts: NextPage = () => {
const orgMembership = useOrgMembershipByPathParam();
const [posts, setPosts] = useState<Post[] | null>(null)
// Fetch whenever the status, orgId, or access token changes
useEffect(() => {
if (orgMembership.status === "user-in-org") {
fetchPosts(orgMembership.org.orgId, orgMembership.accessToken).then((data) => {
setPosts(data)
})
}
}, [orgMembership.status, orgMembership.org?.orgId, orgMembership.accessToken])
if (orgMembership.status === "loading") return <div>Loading...</div>
else if (orgMembership.status === "user-not-in-org") return <div>Not found</div>
else if (!posts) return <div>Loading...</div>
return (
<Container>
<h1>Posts</h1>
{posts.map(post => <IndividualPost key={post.post_id} post={post}/>)}
<CreatePost/>
</Container>
)
}
export default Posts
We basically call fetchPosts as our orgMembership changes and render the pages. For brevity, we’ll leave out the styling of the post itself. As for the fetchPosts method itself:
async function fetchPosts(orgId: string, accessToken: string) {
// Pass the orgId in as a query parameter
const queryParams = new URLSearchParams({orgId: orgId}).toString()
const response = await fetch(`/api/post?${queryParams}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
// Pass the access token in an Authorization header
"Authorization": `Bearer ${accessToken}`
},
})
return await response.json()
}
Since our backend API isn’t customer facing, we can pass in our orgId however we want. As we did above, we can use a query parameter, continue using path parameters, put it in the body of a post request, etc.
Saving posts is very similar, we just need a basic textarea for the text itself and then we’ll use the following function to save the post:
async function savePost(text: string, orgId: string, accessToken: string) {
const qs = new URLSearchParams({"org_id": orgId})
const url = `/api/post?${qs.toString()}`
await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${accessToken}`
},
body: JSON.stringify({"text": text}),
})
}
We now have everything we need on the frontend—the only problem is we don’t have a backend yet. Let’s fix that using Next.js’ built in API routes.
Creating our backend with Next.js API routes and Prisma
Our backend wouldn’t be very useful without some way to store the posts somewhere. We will setup Prisma, a popular TypeScript ORM, which will manage all our database operations, including migrations.
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
Afterwards, you should have a new file in your repo, prisma/schema.prisma
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
This is where we will define our schema. The schema itself is fairly straightforward. We want to have a post which contains some text to display. The post should be associated with a tenant (organization) and a user.
model Post {
post_id String @id @default(uuid())
org_id String
user_id String
text String
}
Nothing too fancy, each post has a post_id that defaults to a new UUID. It contains IDs for the tenant (org_id) and user (user_id). And finally, it contains our text. Now we can migrate our database, which just means to create or update it to the most recent schema:
npx prisma migrate dev --name init
...
Your database is now in sync with your schema.
In the future, if we want to add more fields like a timestamp, we can update our schema and migrate will take care of the rest.
Using Next.js API routes
Next.js API Routes allow you to create your own APIs within Next.js. As Next.js themselves puts it:
Any file inside the folder pages/api is mapped to /api/* and will be treated as an API endpoint instead of a page. They are server-side only bundles and won't increase your client-side bundle size.
Create a new file pages/api/post.ts which will respond to requests at /api/post
We need to check the method first and decide how to handle the request:
import type {NextApiRequest, NextApiResponse} from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<GetHandlerResponse | PostHandlerResponse | string>
) {
if (req.method === "GET") {
await getHandler(req, res);
} else if (req.method === "POST") {
await postHandler(req, res);
} else {
res.status(405).send("Method not allowed")
}
}
We’ll respond to GET requests by listing all posts with an organization/tenant, just like the frontend expects:
import {Post} from '@prisma/client'
export type GetHandlerResponse = Post[]
async function getHandler(
req: NextApiRequest,
res: NextApiResponse<GetHandlerResponse>
) {
const orgId = // TODO how do we know which org we are checking?
const posts = await prisma.post.findMany({
where: {
org_id: orgId,
}
})
return res.status(200).json(posts)
}
You might notice that the Post being imported from @prisma/client is our Post type and includes post_id, org_id, user_id, and text.
But there’s a few problems, first, how do we know which tenant/organization we should fetch posts for? And furthermore, how do we know if the person making API requests is in that tenant?
Earlier, we passed in two pieces of information from our frontend:
- An org_id as a query parameter
- An access token that is verifiable by our backend
PropelAuth, in addition to providing frontend libraries, also provides backend libraries to verify these tokens. We’ll follow the getting started guide for Next.js API routes, which has us use the @propelauth/express library:
yarn add @propelauth/express
Set up our backend by calling initAuth with the parameters given to us in our dashboard:
import {initAuth} from "@propelauth/express";
const propelauth = initAuth({
authUrl: "YOUR_AUTH_URL",
apiKey: "YOUR_API_KEY",
manualTokenVerificationMetadata: {
verifierKey: "YOUR_VERIFER_KEY",
issuer: "YOUR_ISSUER_URL",
}
})
export default propelauth
Finally, we’ll call requireOrgMember, which will make sure that a valid access token was provided AND that the user is in a specified organization. Putting this all together, our getHandler should look like:
import {Post} from '@prisma/client'
import {requireOrgMember, runMiddleware} from "../../lib/propelauth"
export type GetHandlerResponse = Post[]
const requireUserInOrg = requireOrgMember({
// Tell our middleware that the org_id comes from `req.query`
orgIdExtractor: (req) => req.query.org_id,
})
async function getHandler(
req: NextApiRequest,
res: NextApiResponse<GetHandlerResponse>
) {
// Make sure the user is in the specified organization, or else return an error
await runMiddleware(req, res, requireUserInOrg)
const posts = await prisma.post.findMany({
where: {
org_id: req.org.orgId,
}
})
return res.status(200).json(posts)
}
We’ll respond to POST requests by creating a new post and using the same function:
export type PostHandlerResponse = {
post_id: string,
}
async function postHandler(
req: NextApiRequest,
res: NextApiResponse<PostHandlerResponse>
) {
// Make sure the user is in the specified organization, or else return an error
await runMiddleware(req, res, requireUserInOrg)
const post = await prisma.post.create({
data: {
org_id: req.org.orgId, // Not only is the org set, but
user_id: req.user.userId, // the user is also set by requireOrgMember
text: req.body.text,
}
})
res.status(201).json({"post_id": post.post_id})
}
While you are writing this function, you’ll probably notice one of Prisma’s best features—you get both autocomplete and type safety here:
And that’s all! We now have everything we need to test our product.
Testing
Let’s first test that we cannot view other tenant’s data. If we head over to any arbitrary tenant’s posts page, we’ll see the “Not found” message.
But, like we said before, this is just a frontend check. Let’s use cURL to make sure we cannot make requests directly to our backend:
$ curl -v -H "Content-Type: application/json"
-d '{"text": "Hello, tenant I am not a member of!"}'
http://localhost:3000/api/post\?org_id\=93cae2f3-f9a0-429f-84f9-b5dff4004c65
...
< HTTP/1.1 401 Unauthorized
...
As expected, we get a 401 because we didn’t specify an access token signifying we are a valid user. If we do pass in a valid access token, but still pass in an org_id that we are not in, we’ll get a 403, signifying that we don’t have permission to view that organizations data.
Finally, we can create an organization in our hosted UIs that PropelAuth provides:
Make a post and verify that we can view it:
Summary
We’ve built out most of the building blocks we need for a multi-tenant B2B application. Our users can signup, create tenants, and manage the tenant themselves. Our backend is powered by the highly scalable Next.js API routes. And we have both DB migrations and type safety between our application and the database thanks to Prisma.