Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file addedblog/clerk-multitenancy/cover.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
312 changes: 312 additions & 0 deletions blog/clerk-multitenancy/index.mdx
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
---
title: "Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js"
description: Clerk's "organization" feature provides a powerful pre-built tenant management experience. Let's see how we can easily create a full-fledged multi-tenant application with it.
tags: [auth, clerk, multi-tenancy]
authors: yiming
date: 2024-11-24
image: ./cover.png
---

# Building Multi-Tenant Apps Using Clerk's \"Organization\" and Next.js

![Cover Image](cover.png)

Building a full-fledged multi-tenant application can be very challenging. Besides having a flexible sign-up and sign-in system, you also need to implement several other essential pieces:

- Creating and managing tenants
- User invitation flow
- Managing roles and permissions
- Enforcing data segregation and access control throughout the entire application

It sounds like lots of work, and it indeed is. You may have done this multiple times if you're a veteran SaaS developer.

<!--truncate-->

[Clerk](https://clerk.com) is one of the most popular authentication and user management cloud services. Its combination of APIs and pre-built UI components dramatically simplifies the integration of such capabilities into your application. Similarly, its newer "Organization" feature provides an excellent starting point for creating multi-tenant applications. In this post, we'll explore leveraging it to build a non-trivial one while trying to keep our code simple and clean.

## The goal and the stack

The target application we'll build is a Todo List. Its core functionalities are simple: creating lists and managing todos within them. However, the focus will be on the multi-tenancy and access control aspects:

- **Organization management**

Users can create organizations and invite others to join. They can manage members and set their roles.

- **Current context**

Users can choose an organization to be the current context.

- **Data segregation**

Only data within the current organization can be accessed.

- **Role-based access control**

- Admin members have full access to all data within their organization.
- Regular members have full access to the todo lists they own.
- Regular members can view the other members' todo lists and manage their content, as long as the list is not private.

Clerk can be used with any JavaScript framework, but its support for Next.js seems to be the best. So we'll use Next.js as our full-stack framework, along with two other essential pieces of weapon:

- [Prisma](https://prisma.io): the ORM
- [ZenStack](https://zenstack.dev): the access control layer on top of Prisma

You can find the link of the completed project at the end of the post.

## Adding organization management

I assume you've created a Next.js project and set up the basic Clerk sign-up/sign-in flow following [the guide](https://clerk.com/docs/quickstarts/nextjs). Also, make sure you've[ enabled the "Organization" feature](https://clerk.com/docs/organizations/overview) in Clerk's dashboard.

Now, we can add the "OrganizationSwitcher" component into the layout.

```tsx title="src/app/layout.tsx"
// highlight-next-line
import { OrganizationSwitcher } from "@clerk/nextjs";
...

export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<ClerkProvider>
<html lang="en">
<body>
<header>
<SignedOut>
<SignInButton />
</SignedOut>
<SignedIn>
<div>
// highlight-next-line
<OrganizationSwitcher />
<UserButton />
</div>
</SignedIn>
</header>
</body>
</html>
</ClerkProvider>
);
}
```

With this one-liner, you'll have a set of fully working UI components for managing organizations and choosing an active one!

<div align="center">
<img src={require('./org-switcher.png').default} style={{borderRadius: '15px'}} width="480" />
</div>

## Setting up the database

Our user and organization data are stored on Clerk's side. We need to store the todo lists and items in our own database. In this section, we'll set up Prisma and ZenStack and create the database schema.

Let's start with installing the necessary packages:

```bash
npm install --save-dev prisma zenstack
npm install @prisma/client @zenstackhq/runtime
```

Then we can create the database schema. Please note that we're creating a **schema.zmodel** file (as a replacement of "schema.prisma"). The [ZModel language](/docs/the-complete-guide/part1/zmodel) is a superset of Prisma schema language, allowing you to model both the data schema and access control policies. In this section, we'll only focus on the data modeling part.

```zmodel title="/schema.zmodel"
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}

generator js {
provider = "prisma-client-js"
}

// Todo list
model List {
id String @id @default(cuid())
createdAt DateTime @default(now())
title String
private Boolean @default(false)
orgId String?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Make orgId required for proper tenant isolation

The orgId field is marked as optional (String?) which could potentially break tenant isolation. Since this is a multi-tenant application, every list should belong to an organization.

- orgId String?+ orgId String
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
orgId String?
orgId String

ownerId String
todos Todo[]
}

// Todo item
model Todo {
id String @id @default(cuid())
title String
completedAt DateTime?
list List @relation(fields: [listId], references: [id], onDelete: Cascade)
listId String
}
```

You can then generate a regular Prisma schema file and push the schema to the database:

```bash
# The `zenstack generate` command generates the "prisma/schema.prisma" file and runs "prisma generate"
npx zenstack generate
npx prisma db push
```

Finally, create a "src/server/db.ts" file to export the Prisma client:

```ts title="src/server/db.ts"
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();
```

## Implementing access control

As mentioned, ZenStack allows you to model both data and access control in a single schema. Let's see how we can entirely implement our authorization requirements with it. The rules are defined with the `@@allow` and `@@deny` attributes. Access is rejected by default unless explicitly granted with an `@@allow` rule.

Although authorization is a distinct concept from authentication, it usually depends on authentication to work. For example, to determine if the current user has access to a list, a verdict must be made based on the user's id, current organization, and role in the organization. To access such information, let's first declare a type to express it:

```zmodel title="/schema.zmodel"
// The shape of `auth()`
type Auth {
// Current user's ID
userId String @id

// User's current organization ID
currentOrgId String?

// User's role in the current organization
currentOrgRole Role?

@@auth
}
```

Then you can use the special `auth()` function in access policy rules to access the current user's information. Let's use the `List` model as an example to demonstrate how the rules are defined.

```zmodel title="/schema.zmodel"
model List {
...

// deny anonymous access
@@deny('all', auth() == null)

// tenant segregation: deny access if the user's current org doesn't match
@@deny('all', auth().currentOrgId != orgId)

// owner/admin has full access
@@allow('all', auth().userId == ownerId || auth().currentOrgRole == 'org:admin')

// can be read by org members if not private
@@allow('read', !private)

// when create, owner must be set to current user
@@allow('create', ownerId == auth().userId)
}
```

The last piece of the puzzle is, as you may already be wondering, where the value of `auth()` comes from? At runtime, ZenStack offers an `enhance()` API to create an enhanced `PrismaClient` (a lightweighted wrapper) that automatically enforces the access policies. You pass in a user context (usually fetched from the authentication provider) when calling `enhance()`, and that context provides the value for `auth()`.

We'll see how it works in detail in the next section.

## Finally, the UI

Before diving into creating the UI, let's first make a helper to get an enhanced `PrismaClient` for the current user.

```ts title="src/server/db.ts"
import { auth } from "@clerk/nextjs/server";
import { Role } from "@prisma/client";
import { enhance } from "@zenstackhq/runtime";

export async function getUserDb() {
// get the current user's information from Clerk
const { userId, orgId, orgRole } = await auth();

// create an enhanced Prisma Client with proper user context
const user = userId
? {
userId,
currentOrgId: orgId,
currentOrgRole: orgRole
}
: undefined; // anonymous
return enhance(prisma, { user });
}
```

Let's build the UI using [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) (RSC) and [Server Actions](https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations). We'll also consistently use the `getUserDb()` helper to access the database with access control enforcement.

Here's the RSC that renders the todo lists for the current user (with styling omitted):

```tsx title="src/components/TodoList.tsx"
// Component showing Todo list for the current user

export default async function TodoLists() {
const db = await getUserDb();

// enhanced PrismaClient automatically filters out
// the lists that the user doesn't have access to
const lists = await db.list.findMany({
orderBy: { updatedAt: "desc" },
});

return (
<div>
<div>
{/* client component for creating a new List */}
<CreateList />

<ul>
{lists?.map((list) => (
<Link href={`/lists/${list.id}`} key={list.id}>
<li>{list.title}</li>
</Link>
))}
</ul>
</div>
</div>
);
}
```

A client component that creates a new list by calling into a server action:

```tsx title="src/components/CreateList.tsx"
"use client";

import { createList } from "~/app/actions";

export default function CreateList() {
function onCreate() {
const title = prompt("Enter a title for your list");
if (title) {
createList(title);
}
}
Comment on lines +273 to +278

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Replace prompt() with a proper form component

Using prompt() for user input is not recommended for production applications. Consider implementing a proper form component with validation and better UX.

exportdefaultfunctionCreateList(){const[isOpen,setIsOpen]=useState(false);const[title,setTitle]=useState('');asyncfunctiononSubmit(e: React.FormEvent){e.preventDefault();if(title.trim()){awaitcreateList(title);setIsOpen(false);setTitle('');}}return(<><buttononClick={()=>setIsOpen(true)}>Create a list</button>{isOpen&&(<dialogopen><formonSubmit={onSubmit}><inputvalue={title}onChange={(e)=>setTitle(e.target.value)}placeholder="List title"required/><buttontype="submit">Create</button><buttontype="button"onClick={()=>setIsOpen(false)}>
Cancel
</button></form></dialog>)}</>);}


return (
<button onClick={onCreate}>
Create a list
</button>
);
}
```

```ts title="src/app/actions.ts"
'use server';

import { revalidatePath } from "next/cache";
import { getUserDb } from "~/server/db";

export async function createList(title: string) {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
}
```
Comment on lines +294 to +299

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Add error handling to server action

The server action should include error handling and return appropriate error messages to the client.

 export async function createList(title: string) {
+ try {
const db = await getUserDb();
await db.list.create({ data: { title } });
revalidatePath("/");
+ return { success: true };+ } catch (error) {+ console.error('Failed to create list:', error);+ return { + success: false, + error: 'Failed to create list. Please try again.' + };+ }
}
📝 Committable suggestion

‼️IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
exportasyncfunction createList(title:string) {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
}
```
exportasyncfunction createList(title:string) {
try {
const db = await getUserDb();
await db.list.create({data: { title } });
revalidatePath("/");
return {success: true};
} catch (error) {
console.error('Failed to create list:', error);
return {
success: false,
error: 'Failed to create list. Please try again.'
};
}
}


<div align="center">
<img src={require('./list-ui.gif').default} style={{borderRadius: '15px'}} width="640" />
</div>

The components that manage Todo items are not shown for brevity, but the ideas are similar. You can find the fully completed code [here](https://github.com/ymc9/clerk-zenstack-multitenancy).

## Conclusion

Authentication and authorization are two cornerstones of most applications. They can be especially challenging to build for multi-tenant ones. This post demonstrated how the work can be significantly simplified and streamlined by combining Clerk's "Organization" feature and ZenStack's access control capabilities. The end result is a secure application with great flexibility and little boilerplate code.

Clerk also supports defining [custom roles and permissions](https://clerk.com/docs/organizations/roles-permissions) (still Beta) for organizations. Although not covered in this post, with some tweaking, you should be able to leverage it to define access policies. That way, you can manage permissions with Clerk's dashboard and have ZenStack enforce them at runtime.

Binary file addedblog/clerk-multitenancy/list-ui.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file addedblog/clerk-multitenancy/org-switcher.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.