# Clerk Blog — Page 3

# How to Build Multi-Tenant Authentication with Clerk
URL: https://clerk.com/blog/how-to-build-multitenant-authentication-with-clerk.md
Date: 2025-06-27
Category: Guides
Description: Multi-tenancy is one of those architectural decisions that pays off early and compounds over time. Clerk enables you to build multi-tenant authentication with ease.

If you're building a SaaS product, multi-tenancy shouldn’t be an afterthought. Designing with multiple tenants in mind sets you up to scale effectively and securely.

But building multi-tenant logic from scratch can get complicated fast. You need to think through everything from how users join organizations to how sessions are scoped and how permissions are enforced across contexts.

That’s where [Clerk](/user-authentication) comes in.

Clerk’s built-in support for isolating tenants within a single codebase simplifies the heavy lifting. Instead of wiring up custom logic, you can use Clerk’s [drop-in UI components](/docs/components/overview) to get a multi-tenant foundation in place quickly, while still maintaining flexibility as your product grows.

### What You’ll Learn in This Guide

In this guide, we’ll walk through how to use Clerk to build a complete multi-tenant experience into your app. Specifically, you’ll learn how to:

- Let users create and join organizations
- Invite users to organizations with specific roles
- Scope authentication and session logic by organization
- Use our organization switcher so users can switch between multiple tenants
- Enforce role-based access control (RBAC) per organization
- Configure custom domains with Clerk’s Verified Domains

## Building multi-tenancy features into a task manager

To demo this, you’ll learn how to add multi-tenancy features into a task manager called Kozi. At the moment, Kozi is feature complete for individual users, allowing them to add tasks and organize their tasks into projects.

Once the multi-tenancy features are added using Clerk, users will be able to create team workspaces and switch between them. Each workspace will have four distinct roles:

- Admin - the default role, users in this role can perform all actions and manage the organization
- Member - users in this role can create and manage tasks, but not the organization
- Reader - users in this role can only read existing tasks but not make changes

The app is built with Next.js, Clerk, Neon, and Prisma for the ORM.

### Following along

If you want to follow along, clone the [`orgs-exp-start` branch in the Kozi repo](https://github.com/bmorrisondev/kozi/tree/orgs-exp-start) and follow the instructions in the README to get set up.

> \[!NOTE]
> The project also uses Posthog for user activity tracking and analytics, but it’s not necessary to configure that for this article. You can skip that section of the README when configuring the project.

You’ll need the following before you get started:

- [A Clerk account](https://dashboard.clerk.com)
- [A Neon account](https://console.neon.tech/signup)
- Node and NPM installed locally
- Familiarity with Next.js

## Step 1 - Enable Organizations in your Clerk app

Start by accessing your application in the Clerk dashboard. Select **Configure** from the top nav and then **Settings** under **Organization management**. Toggle the **Enable organizations** option to turn the feature on.

Once on, the view will update to present a number of new options. Here are a few notable ones:

- **Allow new members to delete organizations** - When enabled, organization creators will be granted the `Delete organization` permission, allowing them to delete organizations. Turn this off if you want to allow users to create organizations but not delete them.
- **Enable verified domains** - This option allows you to associate domains with organizations, and is something we will explore later in this article.
- **Allow new members to create organizations** - If you want to control organization creation, disabling this option will prevent members to create organizations themselves.

![Enable organizations](./image1.png)

While we’re in the dashboard, create the roles and permissions by heading to **Roles and Permissions**, then select the **Permissions** tab. You’ll use the **Create new permission** button to create two permissions with the following values:

| Permission  | Key               | Description                                        |
| ----------- | ----------------- | -------------------------------------------------- |
| Read tasks  | `org:tasks:read`  | A user who can read organization tasks.            |
| Write tasks | `org:tasks:write` | A user who can create and edit organization tasks. |

Next head back to the **Roles** tab and select the **Create new role** button to create the following role:

| Name   | Key          | Description                     | Permissions                                  |
| ------ | ------------ | ------------------------------- | -------------------------------------------- |
| Reader | `org:reader` | A user who can only read tasks. | `org:sys_memberships:read`, `org:tasks:read` |

Next select both the **Admin** and **Member** roles and add the `org:tasks:read` and `org:tasks:write` permissions to enable those roles to use the new permissions.

> \[!NOTE]
> [You can learn more about roles and permissions in our docs.](/docs/organizations/roles-permissions#roles)

## Step 2 - Let users create and join orgs

Now that the Clerk application is configured, let’s move over to the code. The first change to make is to add the [`<OrganizationSwitcher />`](/docs/components/organization/organization-switcher) component which is an all-in-one component that lets users create organizations, invite other members, and switch between the ones they have access to.

Open the project locally and update the `Sidebar.tsx` component to add the `<OrganizationSwitcher />` component like so:

```tsx {{ filename: 'src/app/app/components/Sidebar.tsx', ins: [12, [97, 104]], del: [11] }}
'use client'

import { cn } from '@/lib/utils'
import { ChevronRightIcon, ChevronLeftIcon, InboxIcon } from 'lucide-react'
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import CreateProjectButton from './CreateProjectButton'
import ProjectLink from './ProjectLink'
import { useProjectStore } from '@/lib/store'
import { UserButton, useUser } from '@clerk/nextjs'
import { OrganizationSwitcher, UserButton, useUser } from '@clerk/nextjs'
import { getProjects } from '../actions'

function Sidebar() {
  const [isCollapsed, setIsCollapsed] = React.useState(false)
  const { projects, setProjects } = useProjectStore()
  const { user } = useUser()
  const router = useRouter()

  const ownerId = user?.id
  const canCreateProjects = true

  // Navigate to /app when ownerId changes
  React.useEffect(() => {
    if (ownerId) {
      router.push('/app')
    }
  }, [ownerId, router])

  // Fetch projects when component mounts or organization changes
  React.useEffect(() => {
    getProjects().then(setProjects)
  }, [setProjects, ownerId])

  return (
    <div
      className={cn(
        'h-screen border-r border-gray-200 bg-gradient-to-b from-blue-50 via-purple-50/80 to-blue-50 p-4 dark:border-gray-800 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-blue-950/20',
        'flex flex-col transition-all duration-300 ease-in-out',
        isCollapsed ? 'w-16' : 'w-64',
      )}
    >
      <nav className="flex-grow space-y-2">
        <div className="flex items-center justify-between gap-2">
          <div
            className={cn(
              'transition-all duration-300',
              isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
            )}
          >
            <UserButton showName />
          </div>
          <button
            onClick={() => setIsCollapsed(!isCollapsed)}
            className="flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-white/75 dark:hover:bg-gray-800/50"
          >
            {isCollapsed ? (
              <ChevronRightIcon className="h-4 w-4" />
            ) : (
              <ChevronLeftIcon className="h-4 w-4" />
            )}
          </button>
        </div>

        <div
          className={cn(
            'transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <Link
            href="/app"
            className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-white/75 dark:text-gray-200 dark:hover:bg-gray-800/50"
          >
            <InboxIcon className="h-4 w-4" />
            <span>Inbox</span>
          </Link>
        </div>

        <div
          className={cn(
            'pt-4 transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <div className="flex items-center justify-between px-3 pb-2 text-xs font-semibold text-gray-500 dark:text-gray-400">
            <span>Projects</span>
            {canCreateProjects && <CreateProjectButton />}
          </div>
          {projects.map((project) => (
            <ProjectLink key={project.id} project={project} isCollapsed={isCollapsed} />
          ))}
        </div>
      </nav>

      <div
        className={cn(
          'mt-auto pt-4 transition-all duration-300',
          isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
        )}
      >
        <OrganizationSwitcher />
      </div>
    </div>
  )
}

export default Sidebar
```

Now the bottom of the sidebar shows a “Personal account” button. This is the `<OrganizationSwitcher />` component. By default every user has a personal account (meaning they do not have an organization active on their account), but clicking that button opens a menu allowing users to create new organizations:

![The organization switcher](./image2.png)

## Step 3 - Creating an organization and inviting members

With the `<OrganizationSwitcher />` in place, let’s use it to create a new organization and invite others to it. Open the switcher and select **Create Organization**. This will bring up a modal where you can set the name of your new organization. I’ll create one called Frontiers:

![Create organization](./image3.png)

After clicking **Create organization**, the form will ask for email addresses of anyone that should be invited to the organization, along with the role those users should be assigned:

![Invite members](./image4.png)

Clerk handles the process of sending email invitations to those users. When the user clicks the **Accept Invitation** button in their email, they’ll be brought to the application where they will then be able to access that organization as well in the switcher.

![Accept invitation](./image5.png)

At this point, Kozi permits users to switch their active organization, but the code still needs to be slightly modified to only return data relevant to that organization. As of now, the current user’s ID is used to filter data returned from the queries.

For example, the following code renders the Inbox page. It includes a query that returns tasks not associated to any projects. The [`auth`](/docs/references/nextjs/auth) helper function from Clerk is parsing the `userId` and setting that to `ownerId` which is in turn used in the query:

```tsx {{ filename: 'src/app/app/page.tsx' }}
import React from 'react'
import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/db'
import TaskList from './components/TaskList'
import { redirect } from 'next/navigation'

export default async function AppHome() {
  const { userId } = await auth()

  if (!userId) {
    return redirect('/sign-in')
  }

  const ownerId = userId

  // Get the user's inbox tasks
  const tasks = await prisma.task.findMany({
    where: {
      owner_id: ownerId,
      project_id: null,
    },
    orderBy: {
      created_at: 'desc',
    },
  })

  return (
    <div className="flex h-screen">
      <TaskList title="Inbox" tasks={tasks} />
    </div>
  )
}
```

The `auth` function also returns an `orgId` if the user has an organization selected (it will return `null` otherwise) so we can modify the code to check for the `orgId` first and default back to the `userId` if it is not present:

```tsx {{ filename: 'src/app/app/page.tsx', ins: [9, 16], del: [8, 15] }}
import React from 'react'
import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/db'
import TaskList from './components/TaskList'
import { redirect } from 'next/navigation'

export default async function AppHome() {
  const { userId } = await auth()
  const { userId, orgId } = await auth()

  if (!userId) {
    return redirect('/sign-in')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  // Get the user's inbox tasks
  const tasks = await prisma.task.findMany({
    where: {
      owner_id: ownerId,
      project_id: null,
    },
    orderBy: {
      created_at: 'desc',
    },
  })

  return (
    <div className="flex h-screen">
      <TaskList title="Inbox" tasks={tasks} />
    </div>
  )
}
```

The same change can be made to the page that renders each project:

```tsx {{ filename: 'src/app/app/projects/[id]/page.tsx', ins: [15, 23], del: [14, 22] }}
import React from 'react'
import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/db'
import { notFound, redirect } from 'next/navigation'
import TaskList from '../../components/TaskList'

interface ProjectPageProps {
  params: Promise<{
    _id: string
  }>
}

export default async function Project({ params }: ProjectPageProps) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()

  // If the user is not logged in, redirect to the sign-in page
  if (!userId) {
    return redirect('/sign-in')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  const { _id } = await params
  const project = await prisma.project.findUnique({
    where: {
      id: _id,
      owner_id: ownerId,
    },
  })

  // Check if the project exists and belongs to the user
  if (!project || project.owner_id !== ownerId) {
    notFound()
  }

  // Get the project tasks
  const tasks = await prisma.task.findMany({
    where: {
      project_id: _id,
      owner_id: ownerId,
    },
    orderBy: {
      created_at: 'desc',
    },
  })

  return (
    <div className="flex h-screen">
      <TaskList title={project.name} tasks={tasks} projectId={project.id} />
    </div>
  )
}
```

And finally, all of the other queries stored in `actions.ts`:

```ts {{ filename: 'src/app/app/actions.ts', ins: [9, 21, 37, 43, 63, 69, 100, 106, 146, 152, 181, 187, 210, 216], del: [8, 20, 36, 42, 62, 68, 99, 105, 145, 151, 180, 186, 209, 215] }}
'use server'

import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createTask(formData: FormData) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()

  if (!userId) {
    throw new Error('Unauthorized')
  }

  const title = formData.get('title') as string
  if (!title?.trim()) {
    throw new Error('Title is required')
  }

  const ownerId = userId
  const ownerId = orgId || userId
  const project_id = formData.get('project_id') as string | null

  await prisma.task.create({
    data: {
      title: title.trim(),
      owner_id: ownerId,
      project_id: project_id || null,
    },
  })

  revalidatePath('/app')
}

export async function toggleTask(taskId: string) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  const task = await prisma.task.findUnique({
    where: { id: taskId },
  })

  if (!task || task.owner_id !== ownerId) {
    throw new Error('Task not found or unauthorized')
  }

  await prisma.task.update({
    where: { id: taskId },
    data: { is_completed: !task.is_completed },
  })

  revalidatePath('/app')
}

export async function updateTask(formData: FormData) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  const id = formData.get('id') as string
  const title = formData.get('title') as string
  const description = formData.get('description') as string

  if (!id || !title?.trim()) {
    throw new Error('Invalid input')
  }

  const task = await prisma.task.findUnique({
    where: { id },
  })

  if (!task || task.owner_id !== ownerId) {
    throw new Error('Task not found or unauthorized')
  }

  await prisma.task.update({
    where: { id },
    data: {
      title: title.trim(),
      description: description?.trim() || null,
    },
  })

  revalidatePath('/app')
}

export async function createProject(formData: FormData) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  const name = formData.get('name') as string
  if (!name?.trim()) {
    throw new Error('Project name is required')
  }

  const project = await prisma.project.create({
    data: {
      name: name.trim(),
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
  return project
}

export async function getProjects() {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  return prisma.project.findMany({
    where: {
      owner_id: ownerId,
    },
    orderBy: {
      created_at: 'asc',
    },
  })
}

export async function updateProject(formData: FormData) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  const id = formData.get('id') as string
  const name = formData.get('name') as string

  if (!id || !name?.trim()) {
    throw new Error('Invalid input')
  }

  const project = await prisma.project.findUnique({
    where: { id },
  })

  if (!project || project.owner_id !== ownerId) {
    throw new Error('Project not found or unauthorized')
  }

  await prisma.project.update({
    where: { id },
    data: {
      name: name.trim(),
    },
  })

  revalidatePath('/app')
}

export async function deleteProject(projectId: string) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  // Delete all tasks associated with the project first
  await prisma.task.deleteMany({
    where: {
      project_id: projectId,
      owner_id: ownerId,
    },
  })

  // Then delete the project
  await prisma.project.delete({
    where: {
      id: projectId,
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
}

export async function deleteTask(taskId: string) {
  const { userId } = await auth()
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = userId
  const ownerId = orgId || userId

  // Delete the task
  await prisma.task.delete({
    where: {
      id: taskId,
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
}
```

The `Sidebar.tsx` renders a list of projects associated with the current user or organization, and while the `getProjects` function in the `actions.ts` file will fetch the correct list of projects, the Sidebar is a client component and we need a way for it to react to changes. Instead of using the `auth` helper function (which is a server-side function), we can use the `useOrganization()` helper to get details about the user’s active organization and react to changes with a `useEffect`.

Make the following changes to `Sidebar.tsx` to update it’s behavior:

```tsx {{ filename: 'src/app/app/components/Sidebar.tsx', ins: [12, 19, 22], del: [11, 23] }}
'use client'

import { cn } from '@/lib/utils'
import { ChevronRightIcon, ChevronLeftIcon, InboxIcon } from 'lucide-react'
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import CreateProjectButton from './CreateProjectButton'
import ProjectLink from './ProjectLink'
import { useProjectStore } from '@/lib/store'
import { OrganizationSwitcher, UserButton, useUser } from '@clerk/nextjs'
import { OrganizationSwitcher, UserButton, useUser, useOrganization } from '@clerk/nextjs'
import { getProjects } from '../actions'

function Sidebar() {
  const [isCollapsed, setIsCollapsed] = React.useState(false)
  const { projects, setProjects } = useProjectStore()
  const { user } = useUser()
  const { organization } = useOrganization()
  const router = useRouter()

  const ownerId = organization?.id || user?.id
  const ownerId = user?.id
  const canCreateProjects = true

  // Navigate to /app when ownerId changes
  React.useEffect(() => {
    if (ownerId) {
      router.push('/app')
    }
  }, [ownerId, router])

  // Fetch projects when component mounts or organization changes
  React.useEffect(() => {
    getProjects().then(setProjects)
  }, [setProjects, ownerId])

  return (
    <div
      className={cn(
        'h-screen border-r border-gray-200 bg-gradient-to-b from-blue-50 via-purple-50/80 to-blue-50 p-4 dark:border-gray-800 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-blue-950/20',
        'flex flex-col transition-all duration-300 ease-in-out',
        isCollapsed ? 'w-16' : 'w-64',
      )}
    >
      <nav className="flex-grow space-y-2">
        <div className="flex items-center justify-between gap-2">
          <div
            className={cn(
              'transition-all duration-300',
              isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
            )}
          >
            <UserButton showName />
          </div>
          <button
            onClick={() => setIsCollapsed(!isCollapsed)}
            className="flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-white/75 dark:hover:bg-gray-800/50"
          >
            {isCollapsed ? (
              <ChevronRightIcon className="h-4 w-4" />
            ) : (
              <ChevronLeftIcon className="h-4 w-4" />
            )}
          </button>
        </div>

        <div
          className={cn(
            'transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <Link
            href="/app"
            className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-white/75 dark:text-gray-200 dark:hover:bg-gray-800/50"
          >
            <InboxIcon className="h-4 w-4" />
            <span>Inbox</span>
          </Link>
        </div>

        <div
          className={cn(
            'pt-4 transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <div className="flex items-center justify-between px-3 pb-2 text-xs font-semibold text-gray-500 dark:text-gray-400">
            <span>Projects</span>
            {canCreateProjects && <CreateProjectButton />}
          </div>
          {projects.map((project) => (
            <ProjectLink key={project.id} project={project} isCollapsed={isCollapsed} />
          ))}
        </div>
      </nav>

      <div
        className={cn(
          'mt-auto pt-4 transition-all duration-300',
          isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
        )}
      >
        <OrganizationSwitcher />
      </div>
    </div>
  )
}

export default Sidebar
```

With this change, the list of projects in the sidebar will react to changing between organizations.

## Step 4 - Gate access using RBAC

Now that users can switch between organizations, you’ll need to handle the different roles assigned to each user. To make this as simple as possible, Clerk offers the `has` helper function which takes a role or permission as a parameter to check to see if the user in the current context has the proper role/permission.

Recall that we created the `Reader` role which should have read-only access to tasks. This role omits the `orgs:tasks:write` permission so we can conditionally render the components differently if the user’s role does not have this permission.

To prevent the user from creating tasks, edit `CreateTaskInput.tsx` as follows:

```tsx {{ filename: 'src/app/app/components/CreateTaskInput.tsx', ins: [9, 19, 20], del: [18] }}
'use client'

import { useState } from 'react'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
import { createTask } from '@/app/app/actions'
import { PlusIcon, LockIcon } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@clerk/nextjs'

interface Props {
  projectId?: string
}

export default function CreateTaskInput({ projectId }: Props) {
  const [title, setTitle] = useState('')
  const [isSubmitting, setIsSubmitting] = useState(false)
  const canCreate = true
  const { has } = useAuth()
  const canCreate = has?.({ permission: 'org:tasks:create' })

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    // Don't create a task if the title is empty or user can't create
    if (!title.trim() || !canCreate) return

    try {
      setIsSubmitting(true)
      const formData = new FormData()
      formData.append('title', title)
      if (projectId) {
        formData.append('project_id', projectId)
      }
      await createTask(formData)
      setTitle('')
    } finally {
      setIsSubmitting(false)
    }
  }

  return (
    <div
      className={cn(
        'group relative w-full rounded-full bg-white p-2 dark:bg-gray-800',
        canCreate
          ? 'focus-within:shadow-[0_4px_20px_-2px_rgba(96,165,250,0.3),0_4px_20px_-2px_rgba(192,132,252,0.3)]'
          : 'opacity-70',
        'transition-shadow duration-200',
      )}
    >
      <div
        className={cn(
          'absolute inset-0 rounded-full bg-gradient-to-r',
          canCreate
            ? 'from-blue-400/25 to-purple-400/25 transition-opacity duration-200 group-focus-within:from-blue-400 group-focus-within:to-purple-400'
            : 'from-gray-300/25 to-gray-400/25',
        )}
      ></div>
      <div className="absolute inset-[1px] rounded-full bg-white transition-all group-focus-within:inset-[2px] dark:bg-gray-800"></div>
      <div className="relative">
        <form onSubmit={handleSubmit} className="flex w-full items-center gap-2">
          {!canCreate && (
            <div className="ml-3 text-gray-400">
              <LockIcon className="h-4 w-4" />
            </div>
          )}
          <Input
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            placeholder={canCreate ? 'Add a task...' : "You don't have permission to create tasks"}
            disabled={!canCreate}
            className="flex-1 border-0 bg-transparent text-gray-900 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 dark:text-gray-100"
          />
          <Button
            type="submit"
            size="icon"
            disabled={isSubmitting || !title.trim() || !canCreate}
            className="flex !h-[30px] !min-h-0 !w-[30px] items-center justify-center !rounded-full !p-0 !leading-none"
          >
            <PlusIcon className="h-4 w-4" />
          </Button>
        </form>
      </div>
    </div>
  )
}
```

To make sure the user can’t edit tasks (including completing/uncompleting them), edit the `TaskCard.tsx` file:

```tsx {{ filename: 'src/app/app/components/TaskCard.tsx', ins: [8, 17, 18], del: [19] }}
'use client'

import React from 'react'
import { toggleTask } from '../actions'
import EditTaskModal from './EditTaskModal'
import { cn } from '@/lib/utils'
import { Task } from '@prisma/client'
import { useAuth } from '@clerk/nextjs'

interface Props {
  task: Task
  projectName?: string
}

export default function TaskCard({ task, projectName }: Props) {
  const [isModalOpen, setIsModalOpen] = React.useState(false)
  const { has } = useAuth()
  const canEdit = has?.({ permission: 'org:tasks:edit' })
  const canEdit = true

  const handleClick = (e: React.MouseEvent) => {
    const target = e.target as HTMLElement
    // Don't open modal if clicking the checkbox
    if (!target.closest('button')) {
      setIsModalOpen(true)
    }
  }

  return (
    <>
      <div
        onClick={handleClick}
        className={cn(
          'cursor-pointer rounded-lg border border-transparent p-2 transition-colors duration-200 hover:border-gray-100 dark:border-gray-800 dark:hover:bg-gray-800/50',
          task.is_completed && 'opacity-50',
        )}
      >
        <div className="flex items-start justify-between">
          <div className="flex items-start gap-3">
            {/* Checkbox */}
            <button
              onClick={(e) => {
                if (!canEdit) {
                  return
                }
                e.stopPropagation()
                toggleTask(task.id)
              }}
              className={cn(
                'mt-1 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded border',
                canEdit
                  ? 'cursor-pointer border-gray-300 hover:border-gray-400 dark:border-gray-600 dark:hover:border-gray-500'
                  : 'cursor-not-allowed border-gray-200 opacity-60 dark:border-gray-700',
              )}
            >
              {task.is_completed && (
                <svg
                  className="h-3 w-3 text-gray-500 dark:text-gray-400"
                  fill="none"
                  viewBox="0 0 24 24"
                  stroke="currentColor"
                >
                  <path
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth={2}
                    d="M5 13l4 4L19 7"
                  />
                </svg>
              )}
            </button>
            {/* Task details */}
            <div>
              <h3
                className={cn(
                  'font-medium',
                  task.is_completed && 'text-gray-400 line-through dark:text-gray-500',
                )}
              >
                {task.title}
              </h3>

              {task.description && (
                <p
                  className={cn(
                    'mt-1 text-sm text-gray-500 dark:text-gray-400',
                    task.is_completed && 'line-through opacity-75',
                  )}
                >
                  {task.description}
                </p>
              )}
            </div>
          </div>
        </div>
      </div>

      <EditTaskModal
        task={task}
        open={isModalOpen}
        onOpenChange={setIsModalOpen}
        projectName={projectName}
        canEdit={canEdit}
      />
    </>
  )
}
```

The Sidebar should also be edited to prevent `Reader` users from seeing the option to add a project:

```tsx {{ filename: 'src/app/app/components/Sidebar.tsx', ins: [13, 21, 24], del: [25] }}
'use client'

import { cn } from '@/lib/utils'
import { ChevronRightIcon, ChevronLeftIcon, InboxIcon } from 'lucide-react'
import React from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import CreateProjectButton from './CreateProjectButton'
import ProjectLink from './ProjectLink'
import { useProjectStore } from '@/lib/store'
import { OrganizationSwitcher, UserButton, useUser, useOrganization } from '@clerk/nextjs'
import { getProjects } from '../actions'
import { useAuth } from '@clerk/nextjs'

function Sidebar() {
  const [isCollapsed, setIsCollapsed] = React.useState(false)
  const { projects, setProjects } = useProjectStore()
  const { user } = useUser()
  const { organization } = useOrganization()
  const router = useRouter()
  const { has } = useAuth()

  const ownerId = organization?.id || user?.id
  const canCreateProjects = has?.({ permission: 'org:projects:create' })
  const canCreateProjects = true

  // Navigate to /app when ownerId changes
  React.useEffect(() => {
    if (ownerId) {
      router.push('/app')
    }
  }, [ownerId, router])

  // Fetch projects when component mounts or organization changes
  React.useEffect(() => {
    getProjects().then(setProjects)
  }, [setProjects, ownerId])

  return (
    <div
      className={cn(
        'h-screen border-r border-gray-200 bg-gradient-to-b from-blue-50 via-purple-50/80 to-blue-50 p-4 dark:border-gray-800 dark:from-blue-950/20 dark:via-purple-950/20 dark:to-blue-950/20',
        'flex flex-col transition-all duration-300 ease-in-out',
        isCollapsed ? 'w-16' : 'w-64',
      )}
    >
      <nav className="flex-grow space-y-2">
        <div className="flex items-center justify-between gap-2">
          <div
            className={cn(
              'transition-all duration-300',
              isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
            )}
          >
            <UserButton showName />
          </div>
          <button
            onClick={() => setIsCollapsed(!isCollapsed)}
            className="flex-shrink-0 rounded-lg p-1 transition-colors hover:bg-white/75 dark:hover:bg-gray-800/50"
          >
            {isCollapsed ? (
              <ChevronRightIcon className="h-4 w-4" />
            ) : (
              <ChevronLeftIcon className="h-4 w-4" />
            )}
          </button>
        </div>

        <div
          className={cn(
            'transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <Link
            href="/app"
            className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-white/75 dark:text-gray-200 dark:hover:bg-gray-800/50"
          >
            <InboxIcon className="h-4 w-4" />
            <span>Inbox</span>
          </Link>
        </div>

        <div
          className={cn(
            'pt-4 transition-all duration-300',
            isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
          )}
        >
          <div className="flex items-center justify-between px-3 pb-2 text-xs font-semibold text-gray-500 dark:text-gray-400">
            <span>Projects</span>
            {canCreateProjects && <CreateProjectButton />}
          </div>
          {projects.map((project) => (
            <ProjectLink key={project.id} project={project} isCollapsed={isCollapsed} />
          ))}
        </div>
      </nav>

      <div
        className={cn(
          'mt-auto pt-4 transition-all duration-300',
          isCollapsed ? 'w-0 overflow-hidden' : 'w-auto',
        )}
      >
        <OrganizationSwitcher />
      </div>
    </div>
  )
}

export default Sidebar
```

Now that the frontend is updated, you also need to modify any backend code to also check the user’s permissions for maximum security. Fortunately the `has` function works the same in server-side operations just like it does on the front end.

For any server action that makes changes to the database, you’ll use `has` to check the permissions like so:

```tsx {{ filename: 'src/app/app/actions.ts', ins: [8, 9, 33, 34, 57, 58, 92, 93, 134, 135, 167, 168, 194, 195] }}
'use server'

import { auth } from '@clerk/nextjs/server'
import { prisma } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createTask(formData: FormData) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const title = formData.get('title') as string
  if (!title?.trim()) {
    throw new Error('Title is required')
  }

  const ownerId = orgId || userId
  const project_id = formData.get('project_id') as string | null

  await prisma.task.create({
    data: {
      title: title.trim(),
      owner_id: ownerId,
      project_id: project_id || null,
    },
  })

  revalidatePath('/app')
}

export async function toggleTask(taskId: string) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  const task = await prisma.task.findUnique({
    where: { id: taskId },
  })

  if (!task || task.owner_id !== ownerId) {
    throw new Error('Task not found or unauthorized')
  }

  await prisma.task.update({
    where: { id: taskId },
    data: { is_completed: !task.is_completed },
  })

  revalidatePath('/app')
}

export async function updateTask(formData: FormData) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  const id = formData.get('id') as string
  const title = formData.get('title') as string
  const description = formData.get('description') as string

  if (!id || !title?.trim()) {
    throw new Error('Invalid input')
  }

  const task = await prisma.task.findUnique({
    where: { id },
  })

  if (!task || task.owner_id !== ownerId) {
    throw new Error('Task not found or unauthorized')
  }

  await prisma.task.update({
    where: { id },
    data: {
      title: title.trim(),
      description: description?.trim() || null,
    },
  })

  revalidatePath('/app')
}

export async function createProject(formData: FormData) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  const name = formData.get('name') as string
  if (!name?.trim()) {
    throw new Error('Project name is required')
  }

  const project = await prisma.project.create({
    data: {
      name: name.trim(),
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
  return project
}

export async function getProjects() {
  const { userId, orgId } = await auth()
  if (!userId) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  return prisma.project.findMany({
    where: {
      owner_id: ownerId,
    },
    orderBy: {
      created_at: 'asc',
    },
  })
}

export async function updateProject(formData: FormData) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  const id = formData.get('id') as string
  const name = formData.get('name') as string

  if (!id || !name?.trim()) {
    throw new Error('Invalid input')
  }

  const project = await prisma.project.findUnique({
    where: { id },
  })

  if (!project || project.owner_id !== ownerId) {
    throw new Error('Project not found or unauthorized')
  }

  await prisma.project.update({
    where: { id },
    data: {
      name: name.trim(),
    },
  })

  revalidatePath('/app')
}

export async function deleteProject(projectId: string) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  // Delete all tasks associated with the project first
  await prisma.task.deleteMany({
    where: {
      project_id: projectId,
      owner_id: ownerId,
    },
  })

  // Then delete the project
  await prisma.project.delete({
    where: {
      id: projectId,
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
}

export async function deleteTask(taskId: string) {
  const { userId, orgId, has } = await auth()
  if (!userId || (orgId && !has?.({ permission: 'org:tasks:write' }))) {
    throw new Error('Unauthorized')
  }

  const ownerId = orgId || userId

  // Delete the task
  await prisma.task.delete({
    where: {
      id: taskId,
      owner_id: ownerId,
    },
  })

  revalidatePath('/app')
}
```

Users with the `Reader` role in a specific organization should now only be able to see tasks in that organization, but not edit them. Furthermore if they switch to another organization where they have the `Member` role, they can add and edit tasks.

## Optional - Add custom domains per org

Using Verified Domains, you can also configure your application to automatically invite users to a specific organization if they sign in with an email address that matches a specific domain. This is extremely helpful for HR or IT teams who need to onboard users into an application. It avoids having them manually create a user record.

As an example, let’s say I want to automatically invite any user who signs in with an `@clerk.dev` domain name into the Frontiers organization. I’ll start by toggling **Enable verified domains** in the organizations settings page shown earlier. I’ll also enable **Automatic invitation** and **Automatic suggestion** to streamline the experience.

![Enable verified domains](./image6.png)

In Kozi, I’ll access the organization settings by opening the `<OrganizationSwitcher />` and selecting Manage. In the modal, I can add a domain to the Verified domains list. I’ll be asked to verify ownership of the domain by entering my email address to receive a six digit code.

![Add verified domain](./image7.png)

Now whenever somebody joins the app with an `@clerk.dev` email address, the `<OrganizationSwitcher />` will automatically list the Frontiers organization as available for me to join and access.

## Conclusion

Multi-tenancy is one of those architectural decisions that pays off early and compounds over time. Whether you're supporting teams, companies, or entire departments, having the right structure in place makes it easier to manage access, enforce security, and scale your product with confidence.

With Clerk, adding multi-tenant support doesn’t require reinventing your auth stack. You get drop-in UI components, APIs, and sensible defaults that help you stay focused on your product, not boilerplate.

As your app grows, so will the complexity of your users and their organizations. This guide gives you a solid foundation to handle that complexity with clarity.

---

# Choosing the right SaaS architecture: Multi-Tenant vs. Single-Tenant
URL: https://clerk.com/blog/multi-tenant-vs-single-tenant.md
Date: 2025-06-27
Category: Company
Description: What's the difference between multi-tenant and single-tenant SaaS architecture? This guide breaks down the pros, cons, and use cases of each model—so you can choose the right B2B SaaS architecture for your app. Learn how multi-tenancy scales efficiently, when single-tenancy is the better fit, and which modern tools make tenant isolation easier than ever.

Building a B2B SaaS application comes with many early decisions. One of the most critical is choosing your SaaS architecture model - namely, whether to use a multi-tenant or single-tenant approach. This choice impacts everything from cost and scalability to security and maintenance. In this article, we'll clarify:

- What is B2B SaaS
- What is multi-tenancy
- How multi-tenant systems work versus single-tenant setups
- The pros and cons of multi-tenant and single-tenant systems
- How to choose the right model for your product's needs

We'll also explore hybrid strategies and introduce modern tools that can help implement tenant isolation and management. The goal is to help developers, early-stage startups, and founders understand the architectural tradeoffs, so you can make an informed decision for your SaaS.

## What is multi-tenant SaaS architecture?

In multi-tenant architecture, a single instance of your application and its underlying infrastructure is shared by multiple customer organizations (tenants). Each tenant's data and accounts are logically isolated, so that customers only see their own data - but they are all running on the same application and database in a shared environment. For example, think of tenants like residents in an apartment building: everyone shares the same structure and utilities, but each apartment is separate and secured from others. This shared model means tenants *share resources like servers, databases, and application instances*, which greatly improves cost efficiency and scalability. New customers can be onboarded quickly since they use the same base infrastructure. Maintenance is easier on the provider's side and development updates ship faster, because there's just one system to update for all customers.

However, multi-tenancy also requires a strong design for tenant isolation. Since data from different customers lives side-by-side in one system, the application must ensure that one tenant cannot access another tenant's data. Techniques like including a tenant ID column on every table, applying Row-Level Security (RLS) policies in the database, or segregating tenants at the schema or database level are commonly used to enforce this isolation. When implemented properly, each tenant's records remain invisible to any other tenant. The trade-off is that individual tenants have less control over the environment - they can't demand unique customizations that affect only their instance without impacting others. Multi-tenant SaaS providers often solve this by offering configuration options that are universally supported rather than one-off custom code per client.

**Key characteristics of multi-tenancy:**

- **Shared application & DB:** Multiple customers use the same application instance and database with logical partitioning for each tenant. The shared resources also mean a noisy neighbor (one tenant over-consuming resources) could impact others if not managed
- **Cost efficiency:** Infrastructure and maintenance costs are amortized across tenants, making it cheaper per customer
- **Scalability:** The vendor can scale the single application to accommodate more tenants easily - adding more computing resources benefits all customers, without spinning up new instances for each
- **Maintenance:** Updates and bug fixes are applied centrally. When the software is updated, all tenants get the new version at once, simplifying maintenance at scale
- **Customization limits:** Tenants generally run on a uniform codebase. Deep customization for one client is limited, since changes affect everyone. Some SaaS allow per-tenant configuration or feature flags, but not separate forks of the code
- **Security considerations:** Extra care is needed to isolate data and requests. A bug in access control could potentially expose data across tenants, so robust authorization checks are critical.

In summary, multi-tenancy is like a high-rise with many apartments: efficient and cost-effective, but with shared infrastructure. It's widely used in modern B2B SaaS platforms because it supports serving many customers on a common, scalable platform.

## What is single-tenant SaaS architecture?

In single-tenant architecture, each customer (tenant) gets their own dedicated instance of the application and database. There is no sharing of resources between tenants - each runs in an isolated environment. This is akin to each customer having their own house, rather than an apartment in a shared building. Because of this isolation, single-tenancy provides a high degree of control and security: a tenant's data and performance are not affected by any other customer, and there's minimal risk of data accidentally leaking between tenants by design. Customers often prefer this model in scenarios where data must be kept completely separate for compliance or when they require extensive customizations.

With a single-tenant SaaS, onboarding a new customer means provisioning a new deployment of your software (potentially a new server, database, and application instance). Each tenant might even get their own subdomain or dedicated environment. Many on-premises enterprise software offerings or managed cloud offerings use single-tenancy, essentially giving each client their own managed copy of the software.

**Key characteristics of single-tenancy:**

- **Dedicated resources:** Each customer has their own application instance, database and sometimes even separate hardware. No other tenant's data resides in that database.
- **Strong isolation:** Because of the physical and logical separation, there is very strong data isolation and security by default. There's no chance of one tenant accidentally querying another tenant's data because it's simply not in the same system. This isolation also eliminates “noisy neighbor” issues - one tenant's heavy usage can't degrade another's performance because their environments are separate.
- **Customization:** With a dedicated environment, clients can often be granted more customization. The provider (or the client) can tweak configurations, custom plugins, or even custom code for that one tenant without affecting others. This is useful for enterprise clients with unique needs.
- **Higher cost:** The downside is cost and overhead. Serving each customer on their own stack means more infrastructure and maintenance per customer, often leading to higher costs that may only be justifiable for high-paying clients. Higher costs can be offset by leveraging in-house developers to manage maintenance and updates. There are fewer economies of scale; 10 customers could mean maintaining 10 separate sets of resources.
- **Maintenance burden:** Updates and fixes need to be applied to each tenant's instance individually or via an automated process that iterates through them. This can make rolling out new features or patches slower and more labor-intensive, since compatibility has to be ensured for each isolated instance. If one tenant decides to stay on an older version, you may end up maintaining multiple versions of the app.
- **Provisioning complexity:** Setting up a new tenant is more complex and time-consuming, as it involves provisioning new servers or containers, running separate installations, etc. Automation can help, but it's inherently more work than simply adding a record in a multi-tenant database.

In summary, single-tenancy gives each customer their own silo - offering maximum control, security, and potential for customization, at the cost of extra overhead. It's often favored by large enterprises or regulated industries that are willing to pay for isolation.

## Multi-tenant vs. single-tenant: Key differences and tradeoffs

The fundamental difference between these models is shared vs. isolated resources. Multi-tenancy is a shared environment (tenants coexist on the same system) whereas single-tenancy is isolated and each tenant stands alone. This leads to a number of tradeoffs in cost, security, flexibility, and more. As mentioned in previous sections, a common analogy is apartments vs. houses: in a multi-tenant “apartment” setup, tenants share utilities and infrastructure; in a single-tenant “house”, each tenant has everything to themselves.

![Multi-tenant apartments vs. single-tenant houses](./apartments-vs-house.png)

Let's break down the key differences between multi-tenant and single-tenant SaaS architectures across several important factors:

| **Aspect**                | **Single-Tenant (One Customer per Instance)**                                                                                                                                                                        | **Multi-Tenant (Many Customers per Instance)**                                                                                                                                                      |
| ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Infrastructure Cost**   | High - dedicated resources for each customer (cost not shared)                                                                                                                                                       | Low - shared infrastructure amortizes cost across tenants                                                                                                                                           |
| **Security & Isolation**  | Very high - data fully isolated in separate systems. Minimal risk of cross-tenant access.                                                                                                                            | High (with proper design) - data is logically isolated, but sharing infrastructure means stricter controls are needed. No other tenant can see your data, but all rely on common security measures. |
| **Customization**         | Easy - each tenant's environment can be customized (even code-level changes) without affecting others.                                                                                                               | Harder - changes affect all tenants, so customization must be via configuration or not at all. Requires smart layering (feature flags, theming, etc.) for per-tenant tweaks.                        |
| **Scalability**           | Difficult - scaling to more customers means spinning up more instances; operational overhead grows linearly with tenants.                                                                                            | Easier - a shared system scales more efficiently; resources are added to one pool that benefits all tenants. Easier to onboard new customers on the same platform quickly.                          |
| **Updates & Maintenance** | Per customer - updates need to be applied to each instance separately. Version drift is possible if some tenants delay updates.                                                                                      | Global - updates deploy to the shared application, so all tenants get the changes at once. Maintenance is centralized (one environment to manage).                                                  |
| **Typical Use Cases**     | Best for heavily regulated or extremely security-conscious apps, and high-paying enterprise clients who need isolation or custom deployments. Often seen in healthcare, finance, or on-premise enterprise offerings. | Best for most modern SaaS targeting a broad market. Ideal when serving many customers with a uniform service (e.g. SMBs, mid-market) where cost efficiency and ease of scaling are crucial.         |

As the comparison shows, single-tenant architecture shines in security, isolation, and flexibility for customization - at the cost of higher infrastructure expenses and maintenance work per client. Multi-tenant architecture excels in efficient resource utilization, scalability, and easier management, but requires careful attention to security and can limit per-tenant customization.

Let's highlight a few of these tradeoffs:

- **Cost Efficiency:** Multi-tenancy is generally far more cost-efficient for the provider (and often the customer) because resources are shared. You're not running 100 separate servers for 100 customers; you might be running a few servers that handle all 100. This lowers hosting costs significantly. Those savings often translate into more affordable pricing for customers as well. Single-tenancy, by contrast, means each customer needs an allocation of compute, memory, storage, etc. that isn't shared, which is why single-tenant or dedicated-hosting plans tend to come at a premium price.
- **Security & Compliance:** While both models can be made secure, single-tenant offers peace of mind through physical isolation, meaning no other users on your database or server. This can simplify compliance with stringent regulations (GDPR, HIPAA, etc.), since data residency and access can be controlled per-customer environment. Multi-tenant systems must enforce strict *logical* isolation to achieve the same effect. That means robust authentication, authorization, and possibly encryption of tenant data. It's entirely possible to make a multi-tenant app highly secure, but it requires careful engineering. The shared nature of multi-tenancy also means trust in the vendor's security measures is essential. A minor misconfiguration could, in worst cases, expose data across tenants, so the margin for error is smaller. For highly regulated industries or customers that demand complete data isolation, a single-tenant (or a hybrid with dedicated databases) might be non-negotiable.
- **Performance & “Noisy Neighbors”:** In a multi-tenant environment, tenants share resources, so one customer's behavior can potentially affect others. For example, if Tenant A suddenly executes a very expensive operation or experiences a traffic spike, it might consume disproportionate CPU/DB resources and slow down Tenant B's experience, the classic “noisy neighbor” problem. Good multi-tenant design mitigates this (through rate limiting, auto-scaling, query optimization, etc.), but it's an inherent risk. In single-tenancy, there are no noisy neighbors. Each tenant has their own dedicated slice of resources, so one tenant's workload can't throttle another. On the other hand,, in single-tenancy, if a customer's instance is idle, those resources sit unused (wasted capacity), whereas multi-tenant pooling would dynamically make that capacity available to others.
- **Customization & Flexibility:** Single-tenant systems allow tailoring the software environment per client. For example, one client could be on a custom version with specific modules enabled, unique branding, or even custom features developed just for them. This is often important for enterprise contracts. Multi-tenant apps usually avoid one-off custom code per tenant. There's one codebase serving all, so customization is typically limited to configuration options or cosmetic branding. Major deviations requested by one customer either have to be built in a configurable way for all customers or politely declined. This keeps the multi-tenant codebase simpler and the upgrade path easier (since you don't have to maintain divergent code), but it can be a deal-breaker for clients who want a highly tailored solution. In early-stage SaaS, supporting heavy customization for individual clients can also dramatically increase complexity, so many startups choose multi-tenancy and enforce uniformity (sometimes at the cost of losing a few big-customization-seeking deals).
- **Maintenance & Deployment:** A multi-tenant SaaS means one deployment to rule them all, simplifying DevOps. You deploy one set of code, run one database (perhaps scaled out with replicas or partitions, but logically one cluster), and monitor one system. Backup, monitoring, and updates are centralized. With single-tenancy, you have to manage potentially dozens or hundreds of deployments. Automation tools (infrastructure as code, container orchestration, etc.) can help manage many instances, but it's inherently more complex. If you discover a critical bug, you might need to roll out the patch to every customer instance. If you update the base software, you have to ensure all those separate environments are updated (and hope none have unique modifications that make the update tricky). Maintaining thousands of separate databases or instances can become a significant operational burden without heavy automation. Many teams eventually look to consolidate or automate such setups because of this overhead.

In practice, most early-stage SaaS startups lean toward multi-tenancy as the default, because it maximizes scalability and minimizes cost and maintenance efforts. It's the model behind successful SaaS products like Salesforce, Slack, or HubSpot. A single codebase serving many clients, with robust tenant isolation built in. That said, *single-tenancy has its place*. If your product deals with extremely sensitive data or if you're targeting enterprise customers that demand dedicated environments (or need on-premise deployments), you may opt for single-tenant or at least offer it as an option.

## Choosing the right model for your SaaS

How should you decide between single-tenant and multi-tenant (or some mix of the two) for your application? Ultimately, it comes down to your product's audience, requirements, and your capacity to manage complexity. Here are some questions and considerations to guide the decision:

- **What is the relationship between your users and their data?** If each customer's data is completely separate and you have many customers with similar needs (classic B2B SaaS), a multi-tenant design makes a lot of sense. You can serve all of them on one platform while keeping data segmented by tenant. On the other hand, if each customer's use of the software is highly unique or requires heavy customization, single-tenant might be more appropriate so you can tailor each instance. Also consider whether customers might ever need to share data across tenants (usually not in B2B SaaS, but in some cases like a multi-org collaboration, it could happen). Cross-tenant data sharing is easier in a multi-tenant app and quite difficult if each tenant is completely isolated on separate systems.
- **Are you selling to large enterprises with unique needs or strict policies?** Big enterprise clients (especially in industries like finance, healthcare, government) often have strict security and compliance requirements. They might even require an isolated environment by policy. Single-tenancy or a hybrid (such as a dedicated database or instance for that client) could be necessary to close those deals. Also, enterprises often request custom features or integrations. A single-tenant deployment might accommodate that without affecting other customers. Conversely, if your target market is startups and small/medium businesses who generally are fine with a standard offering and care more about cost, a multi-tenant SaaS with a one-size-fits-most approach will be more cost-effective and manageable.
- **Do you need fast iteration and uniform updates for all customers?** If you're in a fast-moving space where you plan to deploy new features frequently to all users, multi-tenancy gives you one pipeline. You deploy updates and everyone is on the latest version. This is great for a product-led growth model where everyone should have the latest and greatest features. Single-tenant models can slow down iteration because you might have different customers on different versions, or you have to carefully roll out changes per instance. If offering a consistent, up-to-date experience across the board is a priority, multi-tenancy is attractive.
- **Are infrastructure costs and operational simplicity important at your stage?** Early on, you likely want to minimize DevOps overhead and cost. Multi-tenancy lets you maximize utilization of your resources. You won't be running 10 separate low-traffic servers for 10 customers. You might run one cluster that all 10 share, perhaps at a fraction of the cost. If you're concerned about cloud costs, multi-tenancy is generally more economical. Single-tenancy can become costly, but sometimes those costs can be passed to the customer (e.g., enterprise clients paying for a dedicated environment). If you do go single-tenant, ensure you have the pricing or funding to support potentially under-utilized resources per customer. Also consider if you have the engineering resources to automate deployment and monitoring for many instances. Without automation, a single-tenant approach can quickly overwhelm a small team.

After answering these questions, you may find that the decision is not strictly black-and-white. Many SaaS companies start with a multi-tenant core for the majority of customers, but adapt with some single-tenant elements as needed. For example, you might build a multi-tenant app for most, but offer a premium “dedicated instance” to enterprise customers who require it, effectively a hybrid approach. The good news is that modern software design allows a spectrum of tenancy models. Even within a multi-tenant system, you can achieve a high degree of isolation (for security or performance) by using techniques like separate databases or schemas per tenant, without giving up the efficiencies of a shared application.

In fact, it's possible to get the best of both worlds in many cases: keep your app multi-tenant for ease of development/deployment, but isolate certain resources per tenant. In the next section, we'll look at some of these hybrid strategies and the tools that make them feasible.

## Best tools for tenant isolation

One reason to feel more confident about multi-tenancy today is that there are powerful tools and frameworks that help with tenant isolation and management. You don't have to reinvent the wheel or build a multi-tenant architecture entirely from scratch. Many common challenges (authentication, data partitioning, access control) are solved by modern platforms. Here are a few examples of tools and approaches that support multi-tenant (and hybrid) SaaS architectures:

- [\*\*Clerk](https://clerk.com) (Authentication & User Management):\*\* Handling authentication and user accounts in a multi-tenant app can be complex, especially if users belong to organizations (tenants) and might switch between them. Clerk is a modern auth platform that provides built-in support for [organization-based multi-tenancy](/docs/organizations/overview). It offers pre-built components for managing organizations, switching between orgs, and role-based access control within a multi-tenant application. In other words, Clerk provides the infrastructure for managing users across multiple tenant organizations so you can focus on your app's functionality. For a frontend or full-stack developer, using a service like Clerk means you can easily implement features like inviting users to an organization, organization-specific roles/permissions, and even SSO, without building that from scratch.
- [\*\*Supabase](https://supabase.com/) (Backend with Row Level Security):\*\* Supabase is an open-source Firebase alternative built on PostgreSQL. One effective feature for multi-tenancy is Postgres [Row-Level Security (RLS)](https://supabase.com/docs/guides/database/postgres/row-level-security), which Supabase exposes in a developer-friendly way. RLS allows you to enforce that each query can only see or modify rows belonging to the requester's tenant, at the database level. For example, you can set a policy that `tenant_id` on a row must match the current user's tenant ID for any SELECT/UPDATE to succeed. This means even if a developer makes a mistake in code (like forgetting to add a tenant filter in a query), the database itself won't return another tenant's data. Using Supabase (or vanilla Postgres with RLS) can give you defense-in-depth for data isolation in a multi-tenant schema. It effectively lets you have a single database for all tenants while ensuring strict partitioning of data access. Supabase also handles many other backend concerns (scalability, auth integration, etc.), which can lower the barrier to adopting a secure multi-tenant design.
- [\*\*Prisma](https://www.prisma.io/) (ORM for Multi-Database or Filtered Access):\*\* Prisma is a popular Node.js ORM that can simplify database interactions. It can be helpful in a multi-tenant context in a couple of ways. First, if you choose a separate database per tenant approach (sometimes called the “silo” model), Prisma can manage multiple connections or clients. For instance, dynamically switching the database connection based on the tenant context in your app. The Prisma client can be instantiated with different connection strings at runtime (some teams maintain a map of tenant ID to DB connection). This makes it feasible to implement a database-per-tenant model without a ton of raw SQL boilerplate. Second, if you use a shared database with tenant IDs, Prisma's query capabilities let you easily add a tenant filter to every query (you might use middleware or default scopes). This reduces the chance of forgetting a `WHERE tenant_id =`... clause. Essentially, modern ORMs like Prisma (and others like Sequelize, TypeORM) are aware of multi-tenancy patterns and have documentation and community examples for implementing them. This saves you from writing a lot of repetitive code to enforce tenant separation in queries. Additionally, Prisma's type safety can prevent accidentally mixing up IDs, and so on.
- [\*\*Neon](https://neon.com/) (Serverless Postgres with Branching):\*\* Neon is a cloud Postgres service that offers intriguing features for multi-tenant and hybrid models. Notably, Neon supports [fast branching of databases](https://neon.com/docs/guides/multitenancy). You can create a new database branch quickly and cheaply. This can enable a database-per-tenant strategy with much less overhead than traditionally expected. In classic single-tenancy, having a separate database for each customer was seen as expensive and hard to scale (imagine running thousands of database servers). But Neon's *serverless Postgres* approach (and similar services) can spin up databases on demand and scale them based on usage. Neon's architecture is designed to handle many databases (tenants) efficiently, so you could isolate each tenant at the database level without incurring massive cost or ops burden. Their documentation even highlights the “database per tenant, data isolation without overhead” use case. This means you can achieve the holy grail of each tenant in a separate database (fully isolated, solving noisy neighbors and a lot of compliance issues) while letting Neon handle the scaling, connection pooling, and management behind the scenes. It's an example of how cloud innovations are blurring the line between multi and single tenancy. You can mix and match to get isolation and efficiency.

The overarching theme is that developers today don't need to build multi-tenancy from scratch. Authentication, authorization, and data security (all critical for multi-tenant SaaS) are available as services or frameworks. This not only accelerates development but also often results in a more secure and robust implementation (since these tools are battle-tested). As an early-stage team, leveraging these can let you focus on your core product while still achieving strong tenant isolation.

The bottom line is: choose the architecture that best fits your current needs, knowing that you have tools and options to adjust as you grow. For a young SaaS startup targeting a broad market, multi-tenancy will likely provide a faster path to a scalable and affordable service. As you gain larger customers or encounter specific needs, you can introduce single-tenant elements or tighter isolation where necessary. Modern platforms like Clerk, Supabase, Prisma, and Neon are making it easier than ever to enforce tenant isolation within a multi-tenant framework, giving you confidence that you're not trading off security or reliability for cost and simplicity.

## Conclusion

Choosing between multi-tenant and single-tenant architecture is a foundational decision for a SaaS product. Multi-tenancy offers scalability, cost-efficiency, and ease of management by serving all customers on a unified system. Single-tenancy provides robust isolation, security, and flexibility by giving each customer their own siloed environment. There is no one-size-fits-all answer. The right choice depends on your target customers, the sensitivity of data, compliance requirements, and resources available to your team.

For many B2B SaaS startups, a well-designed multi-tenant architecture is a great default, allowing you to scale up users quickly and deliver a consistent product experience to all. It usually aligns with a fast-moving development cycle and keeping costs under control. But as we've seen, what is multi-tenancy today is not an all-or-nothing proposition; you can achieve a high degree of tenant isolation even in a shared environment using modern techniques and tools. If you do have use cases that call for more isolation (or you're targeting enterprise clients who demand it), you can adopt a hybrid approach. Perhaps dedicating certain resources per tenant or offering a single-tenant option for those who need it.

Architectural tradeoffs are part of every SaaS journey. The good news is that with cloud platforms and frameworks available in 2025, you have a rich toolkit to implement whichever model (or combination) you choose, without reinventing the wheel. By carefully considering your product's needs and leveraging the right tools (from identity management to databases), you can design a SaaS architecture that balances security, performance, and cost-effectiveness. In the end, delivering value to your customers reliably is what matters. Both multi-tenant and single-tenant architectures can achieve that, as long as you understand the nuances and implement them thoughtfully.

---

# Postmortem: June 26, 2025 service outage
URL: https://clerk.com/blog/postmortem-jun-26-2025-service-outage.md
Date: 2025-06-26
Category: Company
Description: Learn more about our service outage, including the timeline of events and our next steps.

On June 26, 2025, all Clerk services were down from 6:16 UTC to 7:01 UTC, caused by an outage of our compute infrastructure that impacted all Clerk customers.

We are deeply sorry for this outage. Clerk is a critical infrastructure component for our customers, and we take our reliability and uptime seriously. We know that any amount of downtime is unacceptable, and regardless of the cause, our system’s reliability is our responsibility and we fell short of our standards and your expectations.

![Graph of request throughput to our services during the outage. (GMT+3)](./graph.png)

*Graph of request throughput to our services during the outage. (GMT+3)*

## Timeline of events

- 6:16 UTC: Downtime begins and the team starts its investigation
- 6:20 UTC: The team determines that there was neither a deploy coincident with the failure,  nor a spike in traffic
- 6:28 UTC: The team identifies that our Google Cloud Run containers are in a continuous restart loop, and receiving `SIGINT` shutdown signals immediately on start
- 6:32 UTC: The team decides to begin preparing a new release, to test if the `SIGINT`s are related to the particular container
- 6:40 UTC: A fresh container is prepared and deployed, and it also immediately receives a `SIGINT`
- 6:41 UTC: Unable to find a root cause, a P1 incident is filed with Google and we begin speaking with their support
- 6:49 UTC: We receive the first indication that there is an incident at Google: *“I’ve inspected your Cloud Run service and we suspect that you’re being impacted by the internal incident. Please allow me some time to confirm more on this while I reach out to the Specialist”*
- 6:50 UTC: We ask Google which incident, since none has been posted on its status page
- 6:55 UTC: Google responds: *”Yes, this seems to be not yet confirmed hence I’m checking with the Cloud Run Specialist to confirm the same”*
- 7:01 UTC: Service is restored
- 7:32 UTC: We receive the first official confirmation of an incident from Google, via an event from their Service Health API:
  ```text
  {
    @type: "type.googleapis.com/google.cloud.servicehealth.logging.v1.EventLog"
    category: "INCIDENT"
    description: "We are experiencing an  issue with Cloud Run beginning at Wednesday, 2025-06-25 23:16 PDT.
    
    Our engineering team continues to investigate the issue.
    
    We will provide an update by Thursday, 2025-06-26 00:45 PDT with current details.
    
    We apologize to all who are affected by the disruption."
    detailedCategory: "CONFIRMED_INCIDENT"
    detailedState: "CONFIRMED"
    impactedLocations: "['us-central1']"
    impactedProductIds: "['9D7d2iNBQWN24zc1VamE']"
    impactedProducts: "['Cloud Run']"
    nextUpdateTime: "2025-06-26T07:45:00Z"
    relevance: "RELATED"
    startTime: "2025-06-26T06:16:42Z"
    state: "ACTIVE"
    symptom: "The impacted customers in the us-central1 region may observe the service issues while using Cloud Run DirectVPC."
    title: "Cloud Run customers are experiencing an issue in us-central1 region"
    updateTime: "2025-06-26T07:32:13.864860Z"
    workaround: "None at this time."
  }
  ```

## What specifically went wrong?

We architected Clerk to be resilient against failures in Google Cloud availability zones, but not entire regions. [Cloud Run is stated to provide zonal redundancy](https://cloud.google.com/run/docs/zonal-redundancy), which implies that this incident was caused by a full regional failure.

On the other hand, if this was truly a regional failure, we would expect many more services to be impacted than just Clerk. While there was [some discussion on Hacker News](https://news.ycombinator.com/item?id=44384860), the blast radius of this event is surprisingly small for a regional failure.

We are awaiting more information from Google about exactly which system failed, and will update this post when it’s received.

**Update (June 27, 12:40AM UTC):** Google has notified us that their root cause analysis would be published by June 30.

**Update (July 8):** Google provided an RCA Summary, titled *"Cloud Run customers are experiencing an issue in “us-central1” region"* (Event ID: `MLFZZXV`)

- On June 25th 23:16 PDT, Cloud Run Direct VPC workloads in the `us-central1` region experienced downtime for a duration of 51 minutes (June 26 00:07 PDT).
- Workloads in our cloud regions are served out of multiple partitions. Every app deployment is randomly assigned to a primary partition (this cannot be controlled by customers, and is not visible to them). In this incident, only one partition was impacted. As a result approximately 15% of Direct VPC workloads in “us-central1” experienced downtime.
- Workloads in a serving partition are typically served from multiple capacity pools. Our capacity management system uses a load balancer configuration for each capacity pool to signal whether or not the capacity in the pool is online. However, for safety reasons the capacity management system is designed to fail open — that is, if the load balancer configuration is not present, customer workloads can still serve from this capacity.
- This incident was the result of the above tooling implementation alongside the design of our scaling for Direct VPC workloads. Post mitigation and confirmation of the issue’s resolution, our product and SRE teams have deep dived into the architecture which led to this incident and is committed to improving our service to ensure there is no recurrence of this issue.

> **Clerk's interpretation** This RCA indicates that a design flaw with Google Cloud Run's zonal redundancy caused our traffic to fail completely, instead of failover into a different capacity pool. This outcome is what we anticipated, and our remediation of regional failovers should prevent similar failures in the future.

## Remediations

When incidents like this happen, we immediately turn our attention toward preventing their recurrence. Regardless of the root cause, it is our responsibility to build a service that is resilient to failures within our infrastructure providers. To that end, we are starting the following remediations:

### Regional failover for compute (immediate)

This incident could have been mitigated with a failover that shifted our Cloud Run traffic to a different region when `us-central1` began failing. Work is starting on this immediately.

### Multi-cloud redundancy for compute

Although Google Cloud Platform (GCP) was remarkably stable for Clerk’s early years, we have faced three major server disruptions since May 2025 that we attribute to GCP incidents. This shows that we need to explore additional redundancy outside of a single cloud vendor.

We will begin investigating multi-cloud redundancy for our compute infrastructure. This would make Clerk resilient to complete service failures of Cloud Run, as well as failure of Google’s Cloud Load Balancer.

### Additional service isolation and redundancy for session management

Any incident in our Session Management service has an outsized impact on our customers, since it results in complete downtime of their service.

Following an incident in February, we isolated our Session Management service from our User Management service, ensuring that bugs in our User Management codebase would not impact the availability of our Session Management service.

Unfortunately, in the event of a compute outage at origin like we saw in this incident, both services still come down.

To further mitigate session management failures, we are exploring architectural changes that will allow Clerk to continue issuing session tokens for a greater variety of incidents. Though a longer-term project, this will include bringing distributed storage and compute to our Session Management service.

## Looking ahead

This list of remediations we are exploring is not exhaustive, and doesn’t represent a final state for our efforts to make Clerk as resilient as possible. We will continue to invest in stability and scalability to make sure our customers can rely on Clerk as a critical service provider.

This was a serious outage, and we know that businesses rely on Clerk. We are again deeply sorry for the impact on our customers and will continue working to improve our reliability going forward.

For any questions, please [contact support](https://clerk.com/contact).

---

# How to Design a Multi-Tenant SaaS Architecture
URL: https://clerk.com/blog/how-to-design-multitenant-saas-architecture.md
Date: 2025-06-18
Category: Guides
Description: Learn how to design a multi-tenant SaaS architecture that scales from your first 10 users to your next 10,000.

Designing for multiple customers in a single application sounds simple—until it's not.

[Multi-tenancy](/b2b-saas) is the process of supporting multiple organizations or customers in a single application. It requires deliberate architectural choices around authentication, authorization, data storage, and performance. As your product grows, so do the expectations around isolation, access control, and performance. Building a solid multi-tenant strategy is the foundation for scale.

In this guide, we’ll walk through the core principles of multi-tenancy, popular database models, [authentication](/user-authentication) flows, and the tools that help you ship a secure, flexible SaaS product faster.

## Key Principles of Multi-Tenant Design

### Data Isolation

The most important rule of multi-tenancy is this: **one tenant should never see another tenant’s data**. This is often enforced at the application layer, but can also be enforced at the database level using Postgres's Row-Level Security (RLS) mechanism. Platforms like Supabase and Neon support this natively.

For stricter isolation, such as in compliance-driven industries, some apps take it further by assigning **a separate database per tenant**—a pattern we’ll explore in more detail shortly.

### Auth Separation

In more security-sensitive applications, you may want to isolate not just data but user identity as well. This could mean creating [**separate user pools** per tenant](/glossary#isolated-user-pool) or requiring tenant-specific authentication flows like custom sign-in URLs.

### Role Scoping

Once users are inside the app, you still need to control *what* they can do. Assigning roles and permissions per tenant is critical to ensure users only access the data and functionality they’re entitled to. This is especially important when users can belong to more than one tenant.

[Role Based Access Control (RBAC)](/glossary#role-based-access-control) is a scalable strategy for ensuring users have the right access within a tenant. It dictates that each user has a role that contains all of the permissions they need to perform their duties, and the authorization logic can check which permissions exist for the user before allowing them to perform the desired action on the system.

### Shared Infrastructure

Most SaaS products operate on **shared infrastructure**, where compute, storage, and codebases are reused across tenants. This reduces operational costs and simplifies deployments. However, one downside is that a noisy tenant (one with heavy usage or bad behavior) could affect others if isolation isn't thoughtfully implemented. This is why it's important to have a good understanding of the different database models and how they can be used to isolate data, as well as a good system to monitor and alert on tenant-level performance.

## Multi-Tenant Authentication Flows

### Making Authentication Tenant-Aware

In a multi-tenant SaaS app, authentication often needs to be aware of which tenant the user is associated with, sometimes before they’ve even signed in. Some tenants may require specific login methods or domain restrictions that your system needs to enforce.

There are a couple of common patterns to handle this:

The system that handles user authentication can preemptively determine which tenant the user belongs to based on their username or domain. For example, if someone signs in with `brian@clerk.dev`, the system can detect the domain and automatically associate the login with the `clerk.dev` organization. At Clerk, we call this “**verified domains**” and it allows new users to sign up with their own domain and automatically be associated with the correct tenant. This makes the experience seamless while maintaining tenant-level security controls.

Another approach is **explicit tenant selection**, where the user is prompted to select their tenant before authenticating. This can be handled via a subdomain, URL path, or even a dropdown based on past login history. Once selected, your app can enforce tenant-specific auth logic for that session.

### Managing Tenant Context for Logged-In Users

If users in your app can belong to more than one organization, you’ll need a way to determine which tenant context they’re operating in at any given time.

This can be tracked using a field on the user account that stores their active tenant, or through a dedicated mapping table in the database that links users to the tenants they belong to along with their associated roles and permissions.

When using [JWT-based authentication](/blog/combining-the-benefits-of-session-tokens-and-jwts), details about the active tenant in which the user is operating can be stored in the token claims. When verified, the system can trust the claims and automatically associate the request with that tenant. Clerk’s B2B tools use this approach, storing the user’s active organization and their permissions directly in the token.

This ensures your app knows not just *who* is logged in—but *what they can do* and *where they are* in the multi-tenant hierarchy.

## Common Database Patterns

The way you model and isolate tenant data has huge implications for scalability and maintainability. Here are the most common strategies:

### Shared DB / Shared Schema

All tenants share the same database and the same table structure. Each record includes a tenant ID to segment the data. This is the easiest setup to manage and makes it straightforward to run queries across tenants—such as for analytics or internal metrics. However, it also introduces a higher risk of accidental data leakage if tenant IDs aren’t properly enforced. Without database-level controls like RLS, you’ll be relying solely on application logic to enforce boundaries.

![Multi-tenant database structure diagram](./multi.png)

### Shared DB / Isolated Schemas

In this model, all tenants share a single database, but each tenant has its own schema. This provides a stronger logical boundary between tenants than a shared schema. You get more security at the database level while still avoiding the overhead of managing many separate databases. That said, you’ll need to apply database changes to every schema, which can be tedious if not automated. Additionally, not all tooling or ORMs support multiple schemas cleanly.

### Isolated DB per Tenant

With this approach, each tenant is given a completely separate database. It offers the highest level of isolation and is often required by enterprise customers in regulated industries. This setup allows you to fine-tune performance and resources per tenant. However, it comes with a significant maintenance cost as migrations and schema changes need to be deployed to every database instance. If you are using a shared application layer, you’ll also need a routing mechanism in your application to connect each user to the correct database.

![Database per tenant diagram](./single.png)

### Hybrid Models

Some SaaS platforms use a mix of the above models. For example, small teams and startups may be placed on a shared schema, while enterprise customers receive isolated databases as part of a premium plan. This hybrid approach gives you the flexibility to scale tenant isolation based on customer needs, without overengineering from day one.

## Modeling Tenants in Your Database

### Structuring Tables for Multi-Tenant Access

The way you design your tables plays a big role in protecting tenant data. Any table that stores tenant-specific records should include a `tenant_id` field, and often a `created_by_user_id` as well. This provides a clear trail of ownership and supports granular permission enforcement.

An example of a `tasks` table with these attributes would look like this:

```sql
CREATE TABLE tasks (
 task_id SERIAL PRIMARY KEY,
 title VARCHAR(255) NOT NULL,
    description TEXT,
 done BOOLEAN DEFAULT FALSE,
 created_by_user_id TEXT NOT NULL,
 tenant_id TEXT NOT NULL
);
```

You’ll also want a way to track which users belong to which tenants and what roles they have. This can be done with mapping tables that link users to tenants along with their access level. Here is an example of these tables:

```sql
-- This table tracks which users
CREATE TABLE user_tenants (
 user_id TEXT NOT NULL,
 tenant_id TEXT NOT NULL
);

-- This would track the role a user has in each tenant
CREATE TABLE user_roles (
 user_id TEXT NOT NULL,
    role VARCHAR(50) NOT NULL,
 tenant_id TEXT NOT NULL
);

-- This table tracks which permissions belong to which role
CREATE TABLE role_permissions (
    role VARCHAR(50) NOT NULL,
 tenant_id TEXT NOT NULL,
 permission_id INT NOT NULL
);

-- This would contain the permission name (ex: tasks.read, tasks.write)
CREATE TABLE permissions (
 permission_id SERIAL PRIMARY KEY,
 permission_name VARCHAR(50) NOT NULL UNIQUE
);
```

Before allowing any sensitive operation, like writing or deleting data, your app should verify the user's permissions for the active tenant. Whether you do that in application logic or using [database-level RLS](https://supabase.com/docs/guides/database/postgres/row-level-security), this check is key to maintaining secure multi-tenant boundaries.

### Associating Requests with the Right Tenant

To properly enforce tenant isolation, every request must be explicitly tied to a tenant. Let’s take a practical look at the two proposed strategies from earlier, using the database structure from the previous section.

**Store the active tenant with the user or session record in the database**

When storing the user’s active tenant ID in the database, a query to the `tasks` table returning all tasks for a user actively in the `org_1234` tenant would look like this:

```sql
SELECT *
  FROM tasks
  WHERE tenant_id = 'org_1234';
```

Now let’s also consider the permissions a user has when inserting a record into the `tasks` table. You’d first want to check to make sure the user has a role with the `tasks.write` permission:

```sql
SELECT 1
FROM user_tenants ut
JOIN user_roles ur ON ut.user_id = ur.user_id AND ut.tenant_id = ur.tenant_id
JOIN role_permissions rp ON ur.role = rp.role AND ur.tenant_id = rp.tenant_id
JOIN permissions p ON rp.permission_id = p.permission_id
WHERE ut.user_id = 'user_123'
  AND p.permission_name = 'tasks.write'
  AND ut.tenant_id = 'org_123'
LIMIT 1;

```

Assuming the above check passes, the following query could then be executed:

```sql
INSERT INTO tasks (
 title,
  description,
 done,
 created_by_user_id,
 tenant_id
) VALUES (
  'Task Title',
  'This is the description of the task.',
 FALSE,
  'user_1234',
  'org_1234'
);
```

**Store the active tenant information in the JWT**

If you store the user’s role and permissions in the JWT, you will check the values of the verified token claims in code before executing the query. For example, Clerk issues tokens with claims that would look like the following.

Note the organization values in the `o` claim and the list of permissions stored in the `perms` claim:

```sql
{
  "azp": "http://localhost:3001",
  "exp": 1749142876,
  "fea": "o:articles",
  "fva": [
    2,
    -1
 ],
  "iat": 1749142816,
  "iss": "https://modest-hog-24.clerk.accounts.dev",
  "jti": "106e6c2a3d141e64dbcf",
  "nbf": 1749142806,
  "o": {
    "fpm": "3",
    "id": "org_1234",
    "per": "read,write",
    "rol": "admin",
    "slg": "echoes"
 },
  "perms": [
    "org:tasks:read",
    "org:tasks:write"
 ],
  "pla": "o:free_org",
  "role": "authenticated",
  "sid": "sess_2y66UuWq2epuWYYmMfkkT59SeA7",
  "sub": "user_2s2XJgQ2iQDUAsTBpem9QTu8Zf7",
  "v": 2
}
```

> \[!NOTE]
> In this example, `perms` was added manually into the session claims in the Clerk dashboard.

The following JavaScript example checks for the `org:tasks:write` permission before inserting a task using the `sub` claim as the user ID and the `o`.`id` as the tenant ID:

```jsx
const { sessionClaims } = await auth() // Using the Clerk `auth` helper function

if (sessionClaims?.perms.includes('org:tasks:write')) {
  const query = `
 INSERT INTO tasks (title, description, done, created_by_user_id, tenant_id)
 VALUES ($1, $2, $3, $4, $5) RETURNING task_id;
 `

  const values = [title, description, done, sessionClaims.sub, sessionClaims.o.id]

  const res = await client.query(query, values)
}
```

## Tools That Help

### Clerk

[Clerk](/company) is a complete user management solution that integrates seamlessly with multi-tenant apps. Through our B2B toolkit, we support organizations (tenants), verified domains, and fine-grained access control through custom roles and permissions. We also support enterprise authentication flows such as SAML and OIDC, multifactor authentication, and passkeys, and can enforce identity providers on a per-tenant basis.

### Database options

Databases are the backbone of tenant isolation. Choosing the right provider and schema strategy impacts not only performance but also how you scale and segment customers.

[**Supabase**](https://supabase.com/) is a great choice for shared-database models. It offers built-in Row-Level Security (RLS), letting you enforce per-tenant access at the database layer itself. Because it's built on Postgres, you can use policies, views, and triggers to implement sophisticated tenant-aware queries while still keeping things performant.

[**Neon**](https://neon.tech/) provides a compelling model for apps that need stronger isolation. It allows you to spin up isolated branches of a database that can be tied to specific tenants, allowing for independent data storage, migrations, and even teardown if needed. You can pair Neon with Vercel or Supabase for shared frontend and auth infrastructure while maintaining hard tenant boundaries at the data level.

[**PlanetScale**](https://planetscale.com/) offers horizontal scaling through Vitess and supports schema branches, though its approach to isolation is more opinionated. It works well for global apps with high throughput demands but requires careful planning when implementing tenant-specific patterns.

### Automating Tenant Infrastructure with AWS or GCP

For teams adopting a **per-tenant infrastructure model**, cloud providers like AWS and GCP offer automation tools to make that feasible at scale.

With **AWS**, you can use CloudFormation, CDK, or Terraform to provision dedicated resources per tenant—databases, S3 buckets, Lambda functions, and even isolated VPCs if required. Services like EventBridge or Step Functions can orchestrate the entire flow: when a new tenant signs up, your system triggers a pipeline that creates their environment, configures access, and notifies your app.

**GCP** offers similar functionality through tools like Deployment Manager, Workflows, and Cloud Functions. You can set up Cloud SQL instances for per-tenant databases, deploy Cloud Run services for tenant-specific APIs, or isolate workloads with separate projects or namespaces. Pub/Sub can act as the event bus that kicks off provisioning workflows based on user actions or internal triggers.

In both cases, treating infrastructure as code makes tenant provisioning consistent, repeatable, and secure. For enterprise-grade SaaS, this approach not only meets isolation and compliance requirements—it becomes a selling point for larger customers who expect guarantees around performance and data segregation.

## Conclusion

Designing for multi-tenancy is about more than just scaling. It’s about trust, flexibility, and maintainability. As your user base grows and your customer’s needs evolve, your architecture should support that growth without adding friction or risk.

The right combination of tools with the proper strategy can help you avoid scaling issues down the road. Using the knowledge and recommended tools outlined in this article, you are now well-equipped to design a multi-tenant SaaS architecture that scales from your first 10 users to your next 10,000.

---

# What is multi-tenancy and why it matters for B2B SaaS
URL: https://clerk.com/blog/what-is-multi-tenancy-and-why-it-matters-for-B2B-SaaS.md
Date: 2025-06-17
Category: Insights
Description: Learn what multi-tenancy is, why it matters for B2B SaaS apps, and how it shapes your architecture decisions.

Let's assume you’re building a SaaS product for engineering teams to manage their cloud repositories and deployment workflows. We’ll call it **“HubGit.”**

You already have some early customers on your waitlist, each with its own team of developers, repositories, and even CI/CD pipelines. Naturally, you want every developer at these companies to authenticate securely, see only their organization’s code and workflows, and collaborate exclusively with their colleagues. But you don’t want to build and maintain a separate app or backend or database for every organization.

That's where multi-tenancy comes in.

Multi-tenancy is the architecture behind most B2B SaaS products today and it's what makes them scalable and cost-effective as their customer base grows. When Slack serves millions of workspaces, or when Shopify powers hundreds of thousands of stores, they're likely not running separate applications for each customer. They're running one powerful multi-tenant system that delivers isolated experiences for every single tenant.

> Ready to build your B2B SaaS with multi-tenant authentication? [Learn more about our B2B SaaS solution](/b2b-saas).

In this article, we’ll explore what multi-tenancy is, why it matters for B2B SaaS, and how to design for it. Specifically across our multi-tenant series, we’ll cover:

- Common architecture patterns, database strategies, and authentication factors/challenges.
- Practical steps for implementing multi-tenant authentication flows that work in production.
- Examples of real-world applications using multi-tenancy

## What is multi-tenancy?

At its core, [multi-tenancy](https://clerk.com/glossary/multi-tenancy) is an architectural design pattern that allows a single instance of an application to serve multiple customers (or "tenants") while keeping their data, configurations, and workflows isolated from one another. Each tenant acts as a separate entity, with unique users, settings, and authentication factors.

![Multi-tenancy architecture diagram](./multitenancy.png)

Take Shopify, for example. When a store owner signs up for Shopify, they're not just getting a traditional user account. They're getting an entire isolated tenant/organization account where their products, orders, customer data, payment settings, themes, and even custom apps are completely separate from every other Shopify store. This demands a full multi-tenant architecture, with tenant-isolated authentication flows where users might belong to multiple organizations, custom security policies per tenant, isolated databases or schemas, and even different compliance requirements for each business.

However, it’s important to mention that not every SaaS app needs multi-tenancy. If you're building a SaaS app that caters directly to individuals and would never cater to groups of individuals, you don't have to worry about multi-tenancy. You literally don't need it. But if you're building a B2C SaaS for shared use cases, or building a B2B SaaS that you plan to sell all the way up to enterprise at some point, the foundation of your architecture has to be multi-tenant.

## Why multi-tenancy for B2B SaaS?

Multi-tenancy is a foundational decision that shapes how SaaS products serve B2B customers with wildly different requirements as they scale.

Think of it this way: businesses are inherently complex. They have multiple departments with employees who need different levels of permissions, and these teams often grow, and members change over time.  Multi-tenancy helps you model these real-world organizational structures and requirements in a clean, programmatic way, from inviting teammates and assigning roles, to isolating usage/data at the org level. It’s designed to handle the complexities of how businesses actually work.

But here's where it gets really interesting. When you have multiple tenants who probably have different business requirements relying on a multi-tenant infrastructure, they're all pushing it to grow in multiple directions. The eCommerce company needs better payment processing. The healthcare startup needs stricter compliance features. The manufacturing company needs bulk data imports. While each tenant may be pushing for improvements in the context of their isolated portion, everyone ends up benefiting from these "isolated improvements" because they all share the same infrastructure.

![Why multi-tenancy for B2B SaaS](./image.png)

There are even more reasons why multi-tenancy is the bread and butter of [SaaS architecture](https://clerk.com/docs/guides/multi-tenant-architecture). Let’s break them down:

- **Faster feature rollouts:** With a single codebase serving all tenants, every feature you launch is immediately available across your customer base. You write code once, and every tenant gets it immediately. Compare this to single-tenant deployments where you're maintaining separate codebases and coordinating dozens of different rollouts. That's a nightmare!
- **Cost efficiency:** Running one shared application that serves multiple tenants is fundamentally cheaper than managing separate applications and deployments for each tenant. You have less infrastructure to monitor, fewer environments to maintain, and simpler disaster recovery SLAs.
- **True scalability**: With multi-tenancy, adding a new customer doesn't mean provisioning new isolated servers or cloud environments. It's often just creating a new organization record in your database. As you grow, your architecture doesn't fundamentally change. Your core multi-tenant design will stay the same whether you're serving 100 tenants or 100,000.

## What are the risks of not going multi-tenant?

Many developers think they can start building their B2B SaaS with a B2C architecture and "add multi-tenancy later," but this approach creates fundamental data model problems that are exponentially harder to fix as you scale. Let's explore some of the technical debt and engineering challenges you'll face if you try to avoid multi-tenancy from early on:

- **Database migrations:** Every schema change would require coordinating migrations across dozens or hundreds of separate databases since tenants are practically isolated. What should typically be a simple column addition to the DB becomes a complex activity requiring unique execution for each tenant, because one failed migration can leave you with inconsistent schemas across your DB with no clean way to recover.
- **Monitoring and debugging complexity:** When issues arise, you can't just check one set of logs or metrics. You're hunting across multiple databases, separate application instances, and isolated environments to understand what went wrong. Root cause analysis becomes a an unending job across disconnected systems, making critical bug fixes take days instead of hours.
- **Resource scaling becomes impossible:** Without shared infrastructure, scaling requires provisioning new servers, databases, and environments for each tenant individually. Auto-scaling becomes impossible when you're managing hundreds of separate deployments. Performance optimization means profiling and tuning dozens of different environments instead of optimizing one shared system.
- **Data backup and disaster recovery:** Each tenant needs separate backup strategies, disaster recovery procedures, and data retention policies. In the event of any disaster, you're not restoring one system, you're coordinating recovery across multiple isolated environments with different states and dependencies.

## Example features of multi-tenant SaaS apps

Let’s look at some practical examples of how multi-tenancy could show up in SaaS applications. These examples highlight how multi-tenancy supports the dynamic needs of B2B customers while still letting your SaaS serve every organization from one shared infrastructure:

- **Collaborative workspaces:** If you’re building a B2B SaaS, your app should natively support collaborative workspaces (sometimes called organizations, teams, or tenants) where users can be invited or request to join based on their roles and permissions. Think of tools like [Slack](https://slack.com/help/articles/115004151203-Workspaces-overview), where each workspace operates as an isolated tenant with its own users, data, and integrations, or [Notion](https://www.notion.com/help/category/sharing-and-collaboration), which allows granular sharing and collaboration settings across teams. Your app must securely manage user invitations, role assignments, organization settings, and seamless tenant switching—all while maintaining strict data isolation.
- **Organizational/Admin dashboards:** Every B2B organization typically has an admin or owner who needs to manage everything from user accounts and memberships to billing, integrations, and usage analytics. Your B2B SaaS must provide a robust account management portal—not just for admins, but also for end users to manage their membership status, security settings, billing details, and MFA preferences. A great example of this is [Stripe’s account management portal](https://docs.stripe.com/dashboard/basics), which sets a high bar for clarity and functionality. Admins should be able to send invites, assign roles, review join requests, and remove users with ease.
- **Roles and permissions:** Supporting bespoke multi-tenant authentication and authorization goes well beyond basic RBAC. Each organization often has its own roles, permissions, and workflows that don’t fit standard, pre-defined templates. While your B2B SaaS should offer default roles like admin and member, it also needs to support custom roles that developers can define to meet the specific needs of each tenant. [Clerk’s roles and permissions system](https://clerk.com/docs/organizations/roles-permissions) is a strong example of how to enable fine-grained access control at the organization level.
- **Org-specific auth flows and settings:**  From custom authentication methods to unique UI and branding requirements, organizations often need configuration settings that are isolated from other tenants. Your B2B SaaS must support these custom policies at the org level—whether it’s enforcing SSO, requiring email verification, or restricting sign-ups to certain domains. A good starting point for building this kind of tenant-aware auth experience is Vercel’s [Next.js + Clerk authentication starter](https://vercel.com/templates/next.js/clerk-authentication-starter), which provides a ready-to-use foundation for customizing auth per organization.

## Up Next: Designing multi-tenant SaaS architectures

Now that we've established why multi-tenancy matters for B2B SaaS, our next post will explore how to design and build these systems. You'll learn how to architect a multi-tenant SaaS platform that scales from your first 10 users to your next 10,000 and beyond.

---

# How OAuth Works
URL: https://clerk.com/blog/how-oauth-works.md
Date: 2025-06-13
Category: Engineering
Description: A practical guide to OAuth Scoped Access that walks through the Authorization Code Flow with real code examples, security best practices, and clear explanations of how third-party app integrations actually work.

OAuth is confusing. There's no getting around it - the specification is complex, the terminology is overloaded, and the security considerations are numerous. But it doesn't have to be intimidating once you understand the core concepts.

This guide focuses on practical understanding over abstract theory. While Clerk handles most OAuth complexity for you with a built-in authorization server, understanding the fundamentals will make you a better developer and help you debug issues when they arise.

Here's what we'll cover:

- **OAuth Scoped Access** - letting third-party apps access user data without sharing passwords
- **The Authorization Code Flow** - the step-by-step process that makes it work
- **Real implementation details** - actual code you can run, not just theory
- **Security essentials** - PKCE, state parameters, and other protections

There are plenty of OAuth articles out there, but many focus on abstract concepts without showing you how the pieces actually fit together. This guide takes a different approach - we'll walk through a complete implementation so you can see exactly how OAuth works in practice.

## What is OAuth?

[OAuth](https://oauth.net/2/) stands for "Open Authorization", and it is a set of standard specifications designed by the [IETF](https://www.ietf.org/) that address how a user can grant a third party application access to some of their data/resources, without providing their login directly.

As an example, imagine that you are a brand manager at a trendy coffee shop, and you have been tasked with writing marketing content and scheduling it to go out at certain times throughout the week. There are many "content planning" apps that will allow you to do this across several social platforms -- LinkedIn, Twitter/X, Facebook, etc -- let's imagine we're using a fictional one called "Content Planner". Content Planner needs to make posts on your behalf on social platforms.

Content Planner *could* just ask you for your email and password for each social platform, but this is less than ideal for several reasons:

- **Restricting access:** You probably do not want to give Content Planner full access to your account -- only the ability to make posts, but restrict access to send DMs, change your password, or delete your account. If Content Planner has a security incident, it would be best to minimize the damage. [Principle of least privilege](https://en.wikipedia.org/wiki/Principle_of_least_privilege), right?
- **Avoiding bot protection issues:** If Content Planner did have your credentials, it would need to sign in as you. Many apps and authentication providers will automatically detect and block any sort of scripted login attempts as a bot, to prevent fraud and bot attacks (shameless plug: Clerk [will do this for your app automatically](/docs/security/bot-protection)). So, it would be tough for Content Planner to actually sign in as you even if you did provide your email/password.
- **Avoiding MFA issues:** It's becoming increasingly common as a security measure to require more than just an email/password. Sending an [OTP](/docs/custom-flows/email-sms-otp) to your phone or email, getting an [authentication code from an app](/docs/custom-flows/email-password-mfa), using a [passkey](/docs/custom-flows/passkeys), etc. are all common methods of increasing the security of your users' accounts (shameless plug once again, each of these are a simple toggle with Clerk). In these cases, an email and password alone wouldn't be sufficient anyway.

What we need here is a legitimate avenue through which you, as a user of Content Planner, can tell Twitter (going to refer to it as Twitter for this post, I know it's "X" now though), LinkedIn, Facebook, etc that you would like to grant access to Content Planner to make posts on your behalf. This type of interaction is exactly what OAuth was designed to handle.

## Common OAuth Terminology

Let's start with some common terminology. This will help to communicate concepts clearly through the rest of the piece.

- **Client**: This is a very generic term that is common in software discussions. When discussing OAuth, however, it has a very specific meaning that is important to really internalize: *A Client is an entity that wants to get something from another entity*.
  Referencing our above example, Content Planner is the Client, since it wants to get permission to make posts from Twitter, Facebook, LinkedIn, etc.
- **Authorization Service**: This refers to the service that is responsible for signing the user in and out and delegating access to the user's account to third parties. For example, users signing into Content Planner with their Twitter account would use Twitter's authorization service. This may or may not be a *server*, though you will often see it referenced as a "authorization server", including in OAuth specs, but we're going to call it a service in this piece, because it's more accurate, and less confusing. This is sometimes also referred to as an "Identity Provider" or "IdP".
- **Resource Service**: This refers to the service that has the resources the client wants access to. Referencing our above example again for Twitter, this would be the Twitter app itself, which is what has the ability to post things. As mentioned above, this may or may not run on a separate server from the authorization service, and you will likely see it referred to as a "resource server" in other writing about OAuth, but we're sticking with "service" in this piece for accuracy. This is sometimes also referred to as an "Service Provider" or "SP".
- **OAuth Access Token**: A string of random characters (which is generally what a "token" is in the realm of web development) or [JWT](https://datatracker.ietf.org/doc/html/rfc7519) that is given to the *Client* by the *Authorization Service* if the OAuth process is completed successfully. The Client can then send this Token to the *Resource Service* as a form of proof that it should be allowed to access what it's trying to access.

With all of this covered, we should be able to piece together a very basic overview of how OAuth works:

![oauth.png](./oauth-flow-full.jpg)

## Other OAuth use cases

We're going to get deeper into the details of how all of this works in a minute, but be aware that OAuth is broader than this. The diagram above is just one way of using OAuth, and there are several others. OAuth as a protocol can be used for creating users and signing in with a regular username and password, for signing in with one of those number codes that you sometimes do with TV apps, for single sign on (where you "Sign in with Google"), for authorizing requests between two servers that don't even involve a human, and much more. In fact, there are *30+ RFCs* describing modifications and extensions to OAuth.

This makes the term "OAuth" fairly confusing in general. When someone refers to using OAuth, are they talking about signing in to their app using a third-party app? Are they talking about building authentication for their app? Or are they talking about granting access to resources on a user's behalf as described above? This confusion makes it really hard to research and learn about OAuth. In order to further clarify this, we are introducing more specific terms for each of these three flows, that we are hoping will be adopted more widely for the benefit of all developers on the OAuth learning journey:

1. **OAuth Scoped Access** - scoped 3rd party data access via OAuth (the one we described above)
2. **OAuth SSO** - sign on to an app through a third-party app, using OAuth (like Sign in with Google, etc)
3. **OAuth User Management** - OAuth as a user registration and sign in/out mechanism (a full authorization service built with OAuth)

In this post, we're going to discuss *OAuth Scoped Access*, the same flow we have been describing since the start.

## The OAuth authorization code flow in action

Now that we have a broad idea of the scope and goals of OAuth, let's get into the specifics for what is likely the most common OAuth flow, called the ["Authorization Code Flow"](https://oauth.net/2/grant-types/authorization-code/), which allows for OAuth scoped access. You are now stepping into the shoes of the developer of the Content Planner app, tasked with *implementing* an OAuth flow with Twitter. We will walk through how to write a Client that interacts with an Authorization Service. We'll use the same example we have been using above, with Content Planner as the Client and Twitter as the Authorization/Resource Service.

> \[!NOTE]
> *All references to Twitter and its API are fictional, just for example purposes.*

1. You make a request to Twitter's API to make a post on your user's behalf. This will fail, since you do not have permission to do so. However, Twitter will return a `www-authenticate` header alongside the 401, which has the value `Bearer resource_metadata=http://api.twitter.com/.well-known/oauth-protected-resource` (as defined by [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)).

2. You can visit this URL and get back information about *where to go* to get an OAuth flow going with Twitter. Here's what you might see as the response:

   ```json
   {
     "resource": "https://api.twitter.com",
     // ⚠️ This line below is the important part!
     "authorization_servers": ["https://auth.twitter.com"],
     "token_types_supported": ["urn:ietf:params:oauth:token-type:access_token"],
     "token_introspection_endpoint": "https://auth.twitter.com/oauth/token",
     "token_introspection_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
     "service_documentation": "https://docs.twitter.com/oauth",
     "authorization_data_types_supported": ["oauth_scope"],
     "authorization_data_locations_supported": ["header"],
     "key_challenges_supported": [
       {
         "challenge_type": "urn:ietf:params:oauth:pkce:code_challenge",
         "challenge_algs": ["S256"]
       }
     ]
   }
   ```

3. You can now select an authorization server (though `authorization_servers` is an array, realistically you are nearly always just going to get just one back), and make a request to that authorization server for its metadata, which outlines the OAuth endpoints. This should be found at our authorization server URL we got from the metadata file above, `https://auth.twitter.com`, then we tack on the established path, `/.well-known/oauth-authorization-server` according to [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). Making a request to this path should return something along these lines:

   ```json
   {
     "issuer": "https://twitter.com",
     // ⚠️ This line below is the important part!
     "authorization_endpoint": "https://auth.twitter.com/oauth/authorize",
     "token_endpoint": "https://auth.twitter.com/oauth/token",
     "token_endpoint_auth_methods_supported": ["client_secret_basic", "none"],
     "token_endpoint_auth_signing_alg_values_supported": ["RS256", "ES256"],
     "userinfo_endpoint": "https://auth.twitter.com/oauth/userinfo",
     "jwks_uri": "https://auth.twitter.com/.well-known/jwks.json",
     "registration_endpoint": "https://auth.twitter.com/oauth/register",
     "scopes_supported": ["openid", "profile", "email"],
     "response_types_supported": ["code"],
     "service_documentation": "https://docs.twitter.com/oauth",
     "ui_locales_supported": ["en-US"]
   }
   ```

   > \[!NOTE]
   > It should be noted that the steps above are not required, nor does every service that has implemented OAuth support them. Normally, all you need to get started with OAuth is the `authorization_endpoint` seen above, so if you know that endpoint already the steps above can be skipped. They are still recommended though - in the case the service makes changes to their endpoints in any way, following the above steps would allow your app to automatically "heal" through this.

4. Now that we have the authorization endpoint, we can kick off the "Authorization Code Flow" in earnest. The way an OAuth flow begins is normally that your user clicks a button or takes some action within your app. In this case, we'll imagine there's a "Connect with Twitter" button in the interface for Content Planner, and the user clicks that button.

   ![connect-twitter.png](./authorization-flow-kickoff.png)

   As the app developer, we can decide what that link looks like, and we'd like for it to point to the *authorization endpoint*, but also provide some extra details according to the OAuth spec. Let's write that code (in React, just for the example):

   ```jsx
   function OAuthConnection() {
     // we just got this from the authorization server metadata
     const authorizeEndpoint = 'https://auth.twitter.com/oauth/authorize'

     // we'll talk about these these below, promise
     const params = {
       response_type: 'code',
       client_id: 'abc123',
       redirect_uri: `https://contentplanner.app/oauth_callback`,
       scope: 'email profile tweet:write tweet_stats:read',
       state: 'random-value',
     }

     // this just takes the params and tacks them on to the authorize endpoint
     // as a querystring
     const authorizeUrl = `${authorizeEndpoint}?${new URLSearchParams(params).toString()}`

     return (
       <div>
         <p>Connect Content Planner to your X account</p>
         <a href={authorizeUrl}>Connect!</a>
       </div>
     )
   }
   ```

   First, let's talk about the "params" specified above, to make sure we understand what's actually going on there:

   - `response_type` - we [mentioned earlier](#other-o-auth-use-cases) that are several different types of OAuth flows, which makes OAuth a rather confusing thing in general. Now we are starting to pay this debt - since there can be several different flows available from the same authorize endpoint, we need to supply this parameter to specify which type of flow we want to run. In this case, if you recall, we're after the *"Authorization Code Flow"*, and the param *"code"* is what tells OAuth that this is what we want.

   - `client_id` - OAuth specifies that *Clients* must *register* with the *Authorization Service* in order for the flow to work. This is so that when the user goes through the flow in which they grant access, the Authorization Service is able to make it clear *whom* the user is granting access to. You may have done something like this before. Normally, this involves making an account with the Resource Service, going into some sort of "developer settings" and creating an "OAuth Application" or "OAuth Client", where you put in a name, a redirect url, sometimes an avatar, etc. When you have created an OAuth client, the UI will provide you with a **Client ID** and **Client Secret**. We'd take that Client ID and use it for this parameter.

   For our example, this would mean logging into Twitter, going into their developer settings, creating an OAuth Client through their UI, and pasting the Client ID in here (or more commonly, pulling it from an environment variable). It's worth noting that OAuth also includes an optional extension called "dynamic client registration" that authorization services can implement, in which there is an API endpoint through which an OAuth Client can be created, rather than through an application's UI, but this isn't super common at the moment. [We'll talk about that more later](#dynamic-client-registration).

   - `redirect_uri` - When our user clicks on the "connect" link, it will send them out to the Authorization Service for Twitter -- after they are done, Twitter needs to send them back to Content Planner. This is the URL that they are sent back to.

   ![redirect-uri.png](./redirect-uri.png)

   - `scope` - *Authorization Services* may define "scopes" which determine what users are able to grant access to. As a *client*, you can then request access to the scopes you need via this param when hitting the authorize endpoint. Here, we're asking for some basic info about the user, as well as the ability to write tweets and read the tweet stats, so we can see how well our scheduled tweets performed.

   - `state` - This is for security, and ensures that the initial authorization request matches up with the response that was sent back. The base purpose of this param is for the *Client* to generate it, then for the *authorization service* to send it back as a query string when it goes to the `redirect_uri`. The *client* then checks it against the initial value to make sure it matches. Leaving this out can expose users to [CSRF](https://owasp.org/www-community/attacks/csrf) attack shenanigans where an attacker can begin an OAuth flow with their own account, then get someone else to click a link that will complete it with *the victim's* account, letting the attacker take over the victim's account. The state param can also be used to pass some data safely through the OAuth redirect flow, since we know we will get it back on the other side.

   Clicking the `authorizeUrl` link will bring users to a "consent screen", which might look something like this:

   ![OAuth consent screen](./consent-screen.png)

   This screen clearly lays out what permissions you are granting as a user and whom you are granting them to. It utilizes the `scopes` that we passed in the query to clarify for the user what they are allowing, and to whom. If the user accepts, they will be sent back to the `redirect_uri` specified in the params, with `state` and `code` as query parameters. The redirect might look something like this:

   ```
   https://contentplanner.app/oauth_callback?code=h89sf89d8hfsd&state=random-value
   ```

5. So now we need to handle the `oauth_callback` route. The next step in the OAuth flow is to take the "code" here, which is referred to as an "Authorization Code" and exchange this for an actual OAuth Token. Let's write that code (express endpoint as an example):

   ```tsx
   app.get('/oauth_callback', async (req, res) => {
     const qs = new URLSearchParams(req.query)
     const code = qs.get('code')
     const state = qs.get('state')
     const error = qs.get('error')

     // Handle user denial or other errors
     if (error) {
       return res.status(400).send(`OAuth error: ${error}`)
     }

     // we'd want to store the state we sent to the authorize endpoint
     // somewhere so we can compare the two here
     if (state !== originalStateValue) {
       return res.status(400).send('State param mismatch')
     }

     // this is the token endpoint we got from the metadata in step 3
     const response = await fetch(tokenEndpoint, {
       method: 'POST',
       headers: {
         'Content-Type': 'application/x-www-form-urlencoded',
       },
       body: new URLSearchParams({
         client_id: process.env.CLIENT_ID,
         client_secret: process.env.CLIENT_SECRET,
         code,
         grant_type: 'authorization_code',
         redirect_uri: `https://contentplanner.app/oauth_callback`,
       }).toString(),
     }).then((res) => res.json())

     // normally we'd store these somewhere secure for use with
     // making requests to the resource server!
     const accessToken = res.access_token
     const refreshToken = res.refresh_token

     res.json({ success: 'true' })
   })
   ```

   > \[!NOTE]
   > You may be asking - why does this happen in two steps? Why return an "authorization code" and not just send back the OAuth token to save time and resources? We skimmed over this detail in the initial diagram for clarity, but this extra "authorization code for access token exchange" is a required part of the authorization code flow.
   >
   > The answer here is security. For me, understanding 'security' things is easiest by hearing an explanation of how an attacker would be able to exploit this if it were done a different way. So in that spirit, let's imagine that, in the name of efficiency, we decide that we're going to return the OAuth token straight into the redirect URL, and skip the authorization code.
   >
   > In this case, the user is in the browser when they hit the "accept" button on the consent screen, and the authorization service then issues a redirect back to the `redirect_uri`, including the token. The OAuth token now appears fully in the user's browser history, it's visible at least briefly in the URL bar, it's accessible to browser extensions, it shows up in CDN logs, ISP logs, server logs, etc -- these are all problematic as they could leak and make the sensitive OAuth token available to attackers. Additionally, because the Client ID is not a sensitive or secret value and is already exposed in the browser, an attacker with access to it could obtain an OAuth token for your service, bypassing client verification via the client secret.
   >
   > It is generally for this reason that we need both a Client ID and Client Secret - the Client ID can be public without issue, but the Client Secret cannot, which is why we never send it through the browser, and only utilize it when making a direct request from our Client to the Authorization Service, as we can see happening in the code example above.
   >
   > Ensuring that the Token exchange happens as a *server to server* connection makes it more secure, since by avoiding the browser, the sensitive Access Token has fewer places that it appears where it could be extracted by an attacker.

6. Now that we have the OAuth Access Token, we can use it to make a request to the Resource Service. In this example, at the scheduled time, Content Planner could hit Twitter's API in order to send the tweet. As long as we include a valid Access Token with the right scopes, it should work just fine. It might look something like this:

   ```tsx
   await fetch('https://api.twitter.com/tweet', {
     method: 'POST',
     headers: {
       Authorization: 'Bearer <my_access_token>',
     },
     body: JSON.stringify({ text: 'Developers developers developers...' }),
   })
   ```

   Because we have included the Access Token with the request, Twitter will allow Content Planner to post this tweet on our behalf, even though it doesn't have our account sign-in details. Hooray!

Sometimes, it can help to actually write this code to fully lock it in to your brain. If that's the case, I'd encourage you to give it a shot! [Here's the implementation that I wrote](https://github.com/clerk/test-oauth-client) to do this, in case it's helpful at all. It didn't take too long and is very little code.

## Common OAuth questions

Understanding the Authorization Code Flow is a huge step forward in understanding OAuth and how it works overall. But there are also a bunch of other details that I was curious about in my own journey to learn about OAuth, which you might be too, so let's address them.

### What happens if you already finished the flow, but now want to request more scopes?

There's no concept of modifying an existing OAuth Access Token. If you want a token with more scopes, you'd go through the flow again from the start, but request more scopes as parameters to the authorization endpoint. You could then replace the existing Access Token with the new one with more scopes.

### Does the Access Token expire? What happens if/when it does?

Yes, normally, they do, as do most types of access tokens. When you get back an Access Token at the end of the flow, the Authorization Service is expected to also return a *Refresh Token.* It has a much longer expiration than the Access Token and can be used to request a new Access Token. Generally, this works as such:

1. You make a request with an Access Token that has just expired, and get back a 401 response from the Resource Service.
2. You assume this is because the Access Token has expired, and make a request to the "Token Endpoint" (you may have noticed this in the authorization server metadata above), with a valid Refresh Token, and it will return a new Access Token and Refresh Token pair.
3. Now you replay the original request with your new Access Token

If the Refresh Token expires due to the user not using the service for a long time, they will need to re-establish the connection by going through the Authorization Code Flow again.

Here's an example of how a token refresh call might look:

```ts
const refreshResponse = await fetch(tokenEndpoint, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: currentRefreshToken,
    client_id: process.env.CLIENT_ID,
    client_secret: process.env.CLIENT_SECRET, // if confidential client
  }).toString(),
})

// Both tokens are replaced for security
const { access_token, refresh_token } = await refreshResponse.json()
```

### Can tokens be opaque tokens or JWTs? What's the difference?

Opaque tokens are random strings that need to be verified on each request with the authorization server. Because they "phone home" to the authorization server to verify on each request, opaque tokens can be instantly revoked, but are also slower since they add an extra step to each request that includes one. JWTs are digitally signed by the issuer, and can be verified without ever contacting the authorization server by [verifying the signature](/docs/backend-requests/resources/tokens-and-signatures#digital-signatures). However, because of this, they cannot be revoked, and in order to stay secure normally have shorter expiration times. Either token type *can* be used as an OAuth Access Token; which one is preferred is up to the developer. Ory provides a very good overview of the tradeoffs [in their article here](https://www.ory.sh/docs/oauth2-oidc/jwt-access-token).

### What if you want to revoke access?

It's expected that an authorization server has a revocation endpoint, which, if hit with a valid OAuth Access Token, will revoke that token's access and make it useless. This, in combination with clearing out any existing stored Access/Refresh Tokens should be enough to remove an OAuth grant and require a fresh new connection if needed.

## Public clients and PKCE

So far, we've been assuming that Content Planner is running on a server where we can safely store the `client_secret` and use it during the token exchange step. This type of OAuth client is called a **confidential client** because it can keep secrets... well, secret.

But what if you want to build a mobile app or a [single-page web application](https://en.wikipedia.org/wiki/Single-page_application) (SPA) that runs entirely in the browser? In these cases, there's no secure server-side environment to store the `client_secret`. Any secret you embed in a mobile app can be extracted by someone with the right tools, and anything in a browser-based app is visible to browser extensions and anyone who opens the developer tools. These are called **public clients** because they cannot securely store secrets.

This creates a problem with our authorization code flow. Remember in step 5 above, we made a server-to-server request that included the `client_secret` to exchange the authorization code for an Access Token? If we can't safely store a `client_secret`, how do we prove to the authorization service that we're the legitimate client and not an attacker who intercepted someone else's authorization code?

The answer is **PKCE** (Proof Key for Code Exchange, pronounced "pixie"), defined in [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636). PKCE replaces the `client_secret` with a dynamically generated proof that only the Client who started the OAuth flow can provide.

Here's how it works: instead of relying on a pre-shared secret, the Client generates a random value called a **code verifier** at the start of each OAuth flow, along with a **code challenge** (which is a hashed version of the code verifier). The Client sends the code challenge when starting the authorization flow, then proves it started the flow by providing the original code verifier during the token exchange.

Let's see how this changes our code example. First, we need to generate the PKCE values when creating our authorization URL:

```tsx
// Helper function to generate a random code verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32)
  crypto.getRandomValues(array)
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

// Helper function to create code challenge from verifier
async function generateCodeChallenge(verifier: string) {
  const encoder = new TextEncoder()
  const data = encoder.encode(verifier)
  const digest = await crypto.subtle.digest('SHA-256', data)
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '')
}

async function OAuthConnection() {
  const authorizeEndpoint = 'https://auth.twitter.com/oauth/authorize'

  // Generate PKCE values using Web Crypto API
  const codeVerifier = generateCodeVerifier()
  const codeChallenge = await generateCodeChallenge(codeVerifier)

  // Store code verifier in browser storage
  sessionStorage.setItem('pkce_code_verifier', codeVerifier)

  const params = {
    response_type: 'code',
    client_id: 'abc123', // we still need this, but no secret required
    redirect_uri: `https://contentplanner.app/oauth_callback`,
    scope: 'email profile tweet:write tweet_stats:read',
    state: 'random-value',
    // PKCE parameters
    code_challenge: codeChallenge,
    code_challenge_method: 'S256', // indicates we used SHA256 hashing
  }

  const authorizeUrl = `${authorizeEndpoint}?${new URLSearchParams(params).toString()}`

  return (
    <div>
      <p>Connect Content Planner to your X account</p>
      <a href={authorizeUrl}>Connect!</a>
    </div>
  )
}
```

Then, in our callback handler, instead of sending a `client_secret`, we send the `code_verifier`:

```ts
// This would run when the user is redirected back to your SPA
async function handleOAuthCallback() {
  const urlParams = new URLSearchParams(window.location.search)
  const code = urlParams.get('code')
  const state = urlParams.get('state')
  const error = urlParams.get('error')

  if (error) {
    console.error(`OAuth error: ${error}`)
    return
  }

  if (state !== originalStateValue) {
    console.error('State param mismatch')
    return
  }

  // Retrieve the code verifier from browser storage
  const codeVerifier = sessionStorage.getItem('pkce_code_verifier')

  if (!codeVerifier) {
    console.error('No code verifier found')
    return
  }

  const tokenEndpoint = 'https://auth.twitter.com/oauth/token'

  const response = await fetch(tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      client_id: 'abc123',
      code,
      grant_type: 'authorization_code',
      redirect_uri: `https://contentplanner.app/oauth_callback`,
      code_verifier: codeVerifier, // PKCE proof instead of secret
    }).toString(),
  }).then((res) => res.json())

  const accessToken = response.access_token
  const refreshToken = response.refresh_token

  // Clean up the stored code verifier
  sessionStorage.removeItem('pkce_code_verifier')

  // Store tokens securely in your SPA (consider using secure storage)
  sessionStorage.setItem('access_token', accessToken)
  sessionStorage.setItem('refresh_token', refreshToken)

  console.log('OAuth flow completed successfully!')
}
```

The security here works because an attacker who intercepts the authorization code still can't use it — they would need the original `code_verifier` to complete the token exchange, and only the client that started the flow has that value.

> \[!NOTE]
> You might wonder: if the `code_verifier` is stored in the browser (like in sessionStorage), couldn't an attacker just steal that instead?
>
> The key difference is *timing* and *access*. The `code_verifier` only exists for the brief duration of the OAuth flow and is likely only accessible to the specific application that created it. A `client_secret`, on the other hand, would be permanently embedded in the app code where it could be extracted by anyone. Additionally, each PKCE flow uses a unique `code_verifier`, so even if one was somehow compromised, it couldn't be reused for other users or future flows.
>
> While these restrictions make it more difficult for an attacker to compromise the OAuth flow, it's not impossible. For this reason, public clients are inherently less secure than confidential clients, and this should be acknowledged when making decisions about which type of client to use.

PKCE is becoming mandatory for many OAuth providers, even for confidential clients, because it provides an additional layer of security. It's considered a best practice to use PKCE for all OAuth flows when possible, regardless of whether your client can store secrets or not. In fact, the most up to date OAuth spec, [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13), requires that PKCE be used for all authorization code flows.

## Dynamic Client Registration

We briefly mentioned above the concept that it was possible to register an OAuth Client with your Authorization Service via API, rather than manually through their UI. This is not required, and many Authorization Services do not implement this capability, but it is possible, and is referred to as *dynamic client registration*, as defined by [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591).

If an Authorization Service implements Dynamic Client Registration, this means that anyone can register an OAuth client through a public API endpoint. There are a variety of scenarios where this might be needed, largely centered around when many different OAuth clients need to be created in a self-serve manner.

However, it does come with some substantial security risks:

- Dynamic client registration creates an unauthenticated, public endpoint that is accessible to anyone on the internet. This means there is no way to trace or verify the identity of who creates OAuth clients, allowing attackers to create Clients anonymously without leaving any audit trail. This lack of accountability makes it extremely difficult to track malicious activity back to its source.
- Attackers can exploit this open access to create thousands of OAuth Clients over time. Even with rate limiting measures in place, determined attackers can slowly build up large numbers of Clients by spreading their registration attempts across extended periods. This proliferation makes legitimate Clients increasingly difficult to identify among potentially fraudulent ones, especially since Client names and metadata can be set to anything, making proper identification significantly harder for administrators.
- The ability to freely register Clients opens the door to sophisticated social engineering attacks. Attackers can create Clients with legitimate-sounding names and descriptions, potentially tricking users into authorizing malicious applications that appear trustworthy. These deceptive Clients can request broad scopes while maintaining their facade of legitimacy, making it extremely difficult for both users and administrators to distinguish legitimate Clients from malicious imposters attempting to harvest user data or gain unauthorized access.
- Implementing dynamic client registration increases the administrative burden required for monitoring and cleanup activities. This additional complexity makes security audits and compliance reviews more challenging, as administrators must sift through potentially thousands of dynamically created clients. The system requires ongoing vigilance to identify and remove malicious Clients, creating a operational overhead that many organizations may not be prepared to handle effectively.

That being said, as long as you have evaluated and accepted the risks, dynamic client registration can be an essential feature in some scenarios.

## OAuth & OIDC

There's another standard that is built on top of and often used with OAuth called ["OpenID Connect"](https://openid.net/developers/how-connect-works/) (OIDC). This standard is written and maintained by a different standards committee, the [OpenID Foundation](https://openid.net/foundation/).

The key addition to OAuth is that when you return the access and refresh tokens, so long as an `openid` scope is included, you should also return another property called `id_token`, which is a JWT that has some basic user information like name, email, profile photo, etc. This helps the *Client* to know who the user is that just went through the flow. Without OIDC, if the *Client* needed to get any details about the user, it would need to use the Access Token to make another request to some API endpoint that returns user info (OIDC also standardizes a ["user info" endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo) for this purpose). With OIDC, this step can be skipped since the user info is returned as part of the initial OAuth response.

There's more in the OIDC specification that expands out more details on session management if you're using OAuth for user login, but we won't address those in this piece, since it's focused on OAuth scoped access.

## Using OAuth with Clerk

Now that you understand how OAuth scoped access works under the hood, you might be ready to make this happen for one of your applications. If you're using Clerk, we have a robust OAuth authorization server built in for you automatically, that's ready to use.

You can navigate to ["Config > OAuth applications" in the Clerk dashboard](https://dashboard.clerk.com/last-active?path=oauth-applications) to register an OAuth application. This gives you back the `client_id` and `client_secret` we discussed, along with all the proper endpoint configurations. For scenarios where you need the dynamic client registration we discussed earlier (like multi-tenant SaaS platforms or developer marketplaces), Clerk also supports this - though it's optional and needs to be explicitly enabled due to the security considerations we covered.

And remember all those security considerations we covered - CSRF protection via the state parameter, PKCE for public clients, secure token storage? Clerk supports all of these automatically, with the most security-conscious implementation out of the box.

If you are looking to build out an OAuth feature for your Clerk application, check out [our step-by-step guide on how to do so](/docs/oauth/oauth-scoped-access). We also have [a minimal demo OAuth client](https://github.com/clerk/test-oauth-client) that can be used to quickly test against Clerk's built-in authorization server.

---

# Synchronize user data from Clerk to Supabase
URL: https://clerk.com/blog/sync-clerk-user-data-to-supabase.md
Date: 2025-06-06
Category: Guides
Description: Learn how to synchronize user data from Clerk to Supabase with webhooks and Supabase Functions

Most web applications aren’t built for just one user.

Whether you’re building admin dashboards, team-based tools, or collaborative writing platforms, you’ll eventually need access to other users in your system.

When you’re using Clerk, there are a few ways to make that happen—each with its own tradeoffs around performance, accuracy, and infrastructure. In this article, you’ll learn how to access user information using Clerk’s **Backend API**, and how to **sync Clerk users to your Supabase database** using Webhooks and Supabase Functions.

> \[!NOTE]
> This article will focus on Next.js and Supabase. We also have [another guide](/blog/read-user-data-guide) on our blog that is a more framework-agnostic method of accessing other user’s data.

## Accessing Users with the Backend API

Clerk’s **Backend API (BAPI)** allows you to securely retrieve data about the data stored within your Clerk application using your own server environment. This includes data about other users, whether that is individual users or members of an organization.

Since Clerk is the source of truth when it comes to information about users in your system, the biggest benefit of accessing user data through the Clerk Backend API is that it will always retrieve the most up-to-date version of that information. When a user updates their record (or an admin updates it for them), the API will reflect those changes immediately, ensuring that your application always has access to the latest profile, metadata, and status values without relying on a separate syncing process.

The following code snippet demonstrates how to call our Backend API with JavaScript to retrieve details about a user:

```tsx
const userId = 'user_123'
const response = await clerkClient.users.getUser(userId)
```

Requests can also be made directly to the API as shown [in our docs](/docs/reference/backend-api).

As with any external API, BAPI usage is subject to rate limits. To prevent hitting those limits, it’s a good idea to add a caching layer, either on the server or in the client.

## Syncing User Data with Webhooks and Supabase Functions

Another approach is to proactively sync user data to your database by using Clerk **Webhooks**.

Once configured, Clerk can send data to your application any time specific events occur—like a user being created or updated. Each time an event is triggered, Clerk will send an HTTP POST request to a defined endpoint, containing the relevant payload.

The following sample code demonstrates the payload that is sent along with a `user.updated` event that is triggered whenever a user record is modified:

```json
{
  "data": {
    "birthday": "",
    "created_at": 1654012591514,
    "email_addresses": [
      {
        "email_address": "example@example.org",
        "id": "idn_29w83yL7CwVlJXylYLxcslromF1",
        "linked_to": [],
        "object": "email_address",
        "reserved": true,
        "verification": {
          "attempts": null,
          "expire_at": null,
          "status": "verified",
          "strategy": "admin"
        }
      }
    ],
    "external_accounts": [],
    "external_id": null,
    "first_name": "Example",
    "gender": "",
    "id": "user_29w83sxmDNGwOuEthce5gg56FcC",
    "image_url": "https://img.clerk.com/xxxxxx",
    "last_name": null,
    "last_sign_in_at": null,
    "object": "user",
    "password_enabled": true,
    "phone_numbers": [],
    "primary_email_address_id": "idn_29w83yL7CwVlJXylYLxcslromF1",
    "primary_phone_number_id": null,
    "primary_web3_wallet_id": null,
    "private_metadata": {},
    "profile_image_url": "https://www.gravatar.com/avatar?d=mp",
    "public_metadata": {},
    "two_factor_enabled": false,
    "unsafe_metadata": {},
    "updated_at": 1654012824306,
    "username": null,
    "web3_wallets": []
  },
  "event_attributes": {
    "http_request": {
      "client_ip": "0.0.0.0",
      "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
    }
  },
  "object": "event",
  "timestamp": 1654012824306,
  "type": "user.updated"
}
```

On the Supabase side, you can create a **Supabase Function** to receive the webhook payload and write the data directly to your database. This gives you local access to user data, which is helpful for scenarios like:

- Joining on user metadata in custom queries
- Performing analytics or reporting
- Enforcing RLS (Row-Level Security) policies that rely on user attributes

The main benefit of this approach is that your application no longer has to reach out to Clerk in real time—user data is already where you need it. The tradeoff is that syncing is asynchronous, meaning there may be a slight delay between when an event is triggered and when the data becomes available. It also introduces more infrastructure and code you’ll need to manage.

## Practical Example

Let’s take a look at how to implement both of these strategies using a real-world project. **Quillmate** is an open-source writing platform built with Next.js and Supabase.

> \[!NOTE]
> You can clone the repository from [it’s home on GitHub](https://github.com/bmorrisondev/quillmate).

To follow along, make sure you have Docker Desktop installed, which is used to build and deploy the function to Supabase.

### Create and configure the Supabase Edge Function

Start by creating the function by running the following command from the root of the project:

```bash
pnpx supabase functions new clerk-webhooks
```

This should create an empty function located at `supabase/functions/clerk-webhooks`. In your terminal, navigate to that directory and run the following command to add the `@clerk/backend` library to the function:

```bash
pnpx deno add npm:@clerk/backend
```

Now open the `index.ts` file from that directory and replace all of the contents with the following code that automatically adds and updates records for users, organizations, and memberships:

```tsx {{ filename: 'supabase/functions/clerk-webhooks/index.ts' }}
import { createClient } from 'npm:@supabase/supabase-js'
import { verifyWebhook } from 'npm:@clerk/backend/webhooks'

Deno.serve(async (req) => {
  // Verify webhook signature
  const webhookSecret = Deno.env.get('CLERK_WEBHOOK_SECRET')

  if (!webhookSecret) {
    return new Response('Webhook secret not configured', { status: 500 })
  }

  const event = await verifyWebhook(req, { signingSecret: webhookSecret })

  // Create supabase client
  const supabaseUrl = Deno.env.get('SUPABASE_URL')
  const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')
  if (!supabaseUrl || !supabaseServiceKey) {
    return new Response('Supabase credentials not configured', { status: 500 })
  }
  const supabase = createClient(supabaseUrl, supabaseServiceKey)

  switch (event.type) {
    case 'user.created': {
      // Handle user creation
      const { data: user, error } = await supabase
        .from('users')
        .insert([
          {
            id: event.data.id,
            first_name: event.data.first_name,
            last_name: event.data.last_name,
            avatar_url: event.data.image_url,
            created_at: new Date(event.data.created_at).toISOString(),
            updated_at: new Date(event.data.updated_at).toISOString(),
          },
        ])
        .select()
        .single()

      if (error) {
        console.error('Error creating user:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ user }), { status: 200 })
    }

    case 'user.updated': {
      // Handle user update
      const { data: user, error } = await supabase
        .from('users')
        .update({
          first_name: event.data.first_name,
          last_name: event.data.last_name,
          avatar_url: event.data.image_url,
          updated_at: new Date(event.data.updated_at).toISOString(),
        })
        .eq('id', event.data.id)
        .select()
        .single()

      if (error) {
        console.error('Error updating user:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ user }), { status: 200 })
    }

    case 'organization.created': {
      // Handle organization creation
      const { data, error } = await supabase
        .from('organizations')
        .insert([
          {
            id: event.data.id,
            name: event.data.name,
            created_at: new Date(event.data.created_at).toISOString(),
            updated_at: new Date(event.data.updated_at).toISOString(),
          },
        ])
        .select()
        .single()

      if (error) {
        console.error('Error updating owner:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ data }), { status: 200 })
    }

    case 'organization.updated': {
      const { data, error } = await supabase
        .from('organizations')
        .update({
          name: event.data.name,
          updated_at: new Date(event.data.updated_at).toISOString(),
        })
        .eq('id', event.data.id)
        .select()
        .single()

      if (error) {
        console.error('Error updating owner:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ data }), { status: 200 })
    }

    case 'organizationMembership.created': {
      const { data, error } = await supabase
        .from('members')
        .insert([
          {
            id: event.data.id,
            user_id: event.data.public_user_data?.user_id,
            organization_id: event.data.organization?.id,
            created_at: new Date(event.data.created_at).toISOString(),
            updated_at: new Date(event.data.updated_at).toISOString(),
          },
        ])
        .select()
        .single()

      if (error) {
        console.error('Error updating member:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ data }), { status: 200 })
    }

    case 'organizationMembership.updated': {
      const { data, error } = await supabase
        .from('members')
        .update({
          user_id: event.data.public_user_data?.user_id,
          organization_id: event.data.organization?.id,
          updated_at: new Date(event.data.updated_at).toISOString(),
        })
        .eq('id', event.data.id)
        .select()
        .single()

      if (error) {
        console.error('Error updating member:', error)
        return new Response(JSON.stringify({ error: error.message }), { status: 500 })
      }

      return new Response(JSON.stringify({ data }), { status: 200 })
    }

    default: {
      // Unhandled event type
      console.log('Unhandled event type:', JSON.stringify(event, null, 2))
      return new Response(JSON.stringify({ success: true }), { status: 200 })
    }
  }
})
```

Finally, deploy the new function with the following command:

```bash
pnpx supabase functions deploy
```

Now open the Supabase dashboard for your project and navigate to **Edge Functions** from the left navigation. Copy the URL of the function.

![The Supabase Edge Functions page with the URL of the function highlighted](./image1.png)

Next, click the name of the function to open it, select the **Details** tab, disable **Enforce JWT Verification**, then **Save changes.** This functionality will interfere with verifying the payload using the Clerk Webhook Secret, which will be configured in the next section.

![Disable JWT verification in the Supabase Edge Functions Details page](./image2.png)

### Configure the Clerk Webhook

Next, head over to the Clerk dashboard for your application and navigate to **Configure > Webhooks**. Click **Add Endpoint**.

![The Clerk Webhooks page with the Add Endpoint button highlighted](./image3.png)

In the form that appears, paste in the URL of the Supabase Edge Function from the previous steps into the **Endpoint URL** field, enable the `user.created` event, and click **Create** at the bottom of the form.

![The Clerk Webhooks page with the Endpoint URL field highlighted](./image4.png)

The configuration for the created webhook endpoint will automatically open up. Click the eye icon near the **Signing Secret** to reveal the secret and copy the value.

![The Clerk Webhooks page with the Signing Secret field highlighted](./image5.png)

Now head back the Supabase dashboard and navigate to **Edge Functions** > **Secrets**. Add a new secret named `CLERK_WEBHOOK_SECRET` and paste in the value you copied from the Clerk dashboard.

![The Supabase Edge Functions Secrets page with the CLERK\_WEBHOOK\_SECRET secret being added](./image6.png)

That’s it! From now on, any new user created in this Clerk application will automatically be synchronized over to the Supabase database. You can actually test it from the Clerk dashboard by opening the webhook, accessing the Testing tab, selecting the `user.created` event, and clicking **Send Example**.

For other events, you’d simply enable the desired events and update the switch statement to parse the data for those events.

## Conclusion

If you need to sync user data from Clerk to Supabase, Edge Functions are a great match to Clerk’s webhooks. When properly configured, you can build the necessary logic to conditionally process incoming event data into any table in your Supabase database and make user data available to join on for analytics, reporting, and other necessary workloads.

---

# Add subscriptions to your SaaS with Clerk Billing
URL: https://clerk.com/blog/add-subscriptions-to-your-saas-with-clerk-billing.md
Date: 2025-05-20
Category: Company
Description: Learn how to quickly monetize your SaaS with subscriptions powered by Clerk Billing.

Monetizing your application is often the next logical step after building something users love.

Subscription plans are a common strategy to build sustainable revenue into your SaaS, enabling premium features for individual users or teams with active plans. However, implementing subscriptions from scratch can be time-consuming and error-prone due to the complexity of the underlying infrastructure and logic.

**That's why we built Billing**.

Just as Clerk streamlines authentication and user management, it now does the same for subscriptions. You get a polished UI that allows your users to easily select and manage their preferred plan, as well as helper functions to easily gate access to premium features, all without writing custom billing logic from scratch.

In this article, you'll learn what Clerk Billing is, how it works, and how to implement it in a real-world application.

## What Is Clerk Billing?

[**Clerk Billing**](/billing) is our latest offering that brings subscription management directly into your existing authentication stack. With just a few clicks in the [Dashboard](https://dashboard.clerk.com/), you can define subscription plans and their associated features, which are displayed in your application with the drop-in [`<PricingTable />`](/docs/components/pricing-table) component.

Clerk integrates directly with your Stripe account, letting Stripe handle the actual payment processing while Clerk handles the user interface and entitlement logic. During development, you can even work in a sandbox environment without requiring a Stripe account. This mirrors the way Clerk handles SSO, where development instances use shared credentials until you're ready to go live.

## How Billing Works

You'll start in the [Clerk dashboard](https://dashboard.clerk.com/), where you define your **plans** and add **features** to them.

**Features** are essentially flags that indicate what a user has access to. For example, if your “Pro” plan includes advanced analytics, you'd create an “Analytics” feature and assign it to the plan. Features can be shared across multiple plans, allowing you to build a pricing structure thats increases access to your application as the selected plan increases.

You can configure your plans to be billed monthly or offer discounts for annual subscribers. Once your plans are created, you're ready to display them in your app.

### The `<PricingTable />` component

The core UI component used with Billing is the [`<PricingTable />`](/docs/components/pricing-table). It's a single-line component that renders a fully functional plan selector and payment form inside your app.

![The PricingTable component](./pricingtable.png)

When a user selects a plan, a modal drawer will open to collect their payment details. It's a smooth and familiar experience for users and requires no custom form building on your part.

Users can also subscribe to new plans and manage existing subscriptions directly through the [`<UserProfile />`](/docs/components/user/user-profile) component. The new Billing tab also includes invoice history and linked payment sources. This centralizes authentication, profile management, and billing into one cohesive experience.

### Verifying the User's access

One of the most powerful features in Clerk is the [`has()`](/docs/references/backend/types/auth-object#has) helper. Originally built to power B2B access controls, it checks whether a user has a specific role or permission. With Billing, it now supports checking a user's plan or feature (entitlement) access.

```tsx
const { has } = await auth()

const hasPremiumPlan = has({ plan: 'gold' })
const hasWidgets = has({ feature: 'widgets' })
```

This makes it incredibly easy to gate access to premium content or features with a single, readable function call.

### Managing Subscriptions

Once users are subscribed, you can manage their subscriptions directly from the Clerk dashboard. There's a new [**Subscriptions**](https://dashboard.clerk.com/last-active?path=billing/plans) tab where you can search for users, view their subscription status, and even cancel plans if needed.

Cancelled plans won't immediately remove access, they'll simply stop renewing, giving your users access until the current billing cycle ends. You can also view a user's plan details at a glance, which is especially useful for support and admin workflows.

## Implementing Billing in Quillmate

To demonstrate how easy it is to implement Billing into an application, I'm going to add subscriptions to **Quillmate**, a web-based writing platform built with Next.js, Clerk, and Supabase. The Pro plan for Quillmate offers an AI assistant that users can access while writing new articles. If the user is not a subscriber, they will be prompted to subscribe when they attempt to access the chat assistant.

> \[!NOTE]
> You can access the completed version of the project on [GitHub](https://github.com/bmorrisondev/quillmate/tree/billing).

### Creating the plans

I'll start in the [Clerk dashboard](https://dashboard.clerk.com/) and navigate to **Configure > Subscription Plans**, then click **Add User Plan**.

![The Subscription Plans configurate page with a red arrow pointing to the "Add User Plan" button](./1-creating_subscription_plans.png)

In the next screen I'll name the plan (which will auto-populate the slug), add a description, and set the monthly price.

![The plan configuration page](./2-naming-plans.png)

Before saving I'll scroll down a bit to create the feature that's associated with the plan by click **Add Feature**. I'll name the feature “AI Assistant” and provide a description before saving. Take note of the slug as it will be used in the code to check if the user can access this feature.

![The plan configuration page with a red arrow pointing to the "Add Feature" button](./3-add_feature.png)

### Add the pricing table

Now that my plan and feature are created, I can start updating the code. The first thing I'm going to do is create a new page that shows the available subscriptions. This will be a page that users can access at `/subscribe`. The page itself contains some promo text, but the main thing to note is the [`<PricingTable />`](/docs/components/pricing-table) component which is all I need to render the available plans and features:

```tsx {{ filename: 'src/app/subscribe/page.tsx', ins: [1, 47] }}
import { PricingTable } from '@clerk/nextjs'
import React from 'react'
import Link from 'next/link'

function SubscribePage() {
  return (
    <>
      {/* Navigation Bar */}
      <nav className="fixed top-0 left-0 z-20 flex w-full items-center justify-between border-b border-gray-200 bg-white/70 px-4 py-3 backdrop-blur-md dark:border-gray-800 dark:bg-gray-950/70">
        <Link
          href="/"
          className="bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-lg font-semibold text-transparent transition-opacity hover:opacity-80"
        >
          ← Back to Home
        </Link>
      </nav>
      {/* Main Content */}
      <div className="flex min-h-svh items-center justify-center bg-gradient-to-b from-white to-gray-50 pt-20 dark:from-gray-950 dark:to-gray-900">
        <div className="flex w-full max-w-2xl flex-col items-center gap-8 p-8 text-center">
          <h1 className="bg-gradient-to-r from-purple-600 to-blue-600 bg-clip-text text-4xl font-bold text-transparent">
            Unlock AI Superpowers
          </h1>
          <p className="text-xl text-gray-600 dark:text-gray-300">
            Become a member to access exclusive AI features, priority support, and early access to
            new tools. Join our growing community and take your productivity to the next level!
          </p>
          <ul className="mx-auto mb-4 w-full max-w-md text-left text-base text-gray-700 dark:text-gray-200">
            <li className="mb-2 flex items-center gap-2">
              ✅ <span>Unlimited AI queries and content generation</span>
            </li>
            <li className="mb-2 flex items-center gap-2">
              ✅ <span>Early access to new AI-powered features</span>
            </li>
            <li className="mb-2 flex items-center gap-2">
              ✅ <span>Priority email & chat support</span>
            </li>
            <li className="flex items-center gap-2">
              ✅ <span>Member-only resources and tutorials</span>
            </li>
          </ul>
          <div className="mb-4">
            <span className="text-lg font-semibold text-blue-600 dark:text-blue-400">
              Ready to get started? Choose your plan below:
            </span>
          </div>
          <div className="flex w-full justify-center">
            <PricingTable />
          </div>
        </div>
      </div>
    </>
  )
}

export default SubscribePage
```

This page shows a list of available plans and features for Quillmate users:

![The Quillmate subscribe page](./4-quillmate_plans.png)

Selecting **Get Started** under the plan will simply open a drawer where I can enter my payment information to be processed by Stripe!

![The subscribe page with the checkout drawer open](./5-checkout.png)

### Protecting the AI Chat feature

For each article, Quillmate has a floating action button in the lower right that users can click to access the assistant. This feature should only be available to users who subscribe to the Pro plan, or more specifically, a plan with the “AI Assistant” feature.

The code for the floating action button has a simple check that uses the `has` helper from the Clerk SDK to check if the current user has a plan that includes the `ai_assistant` feature, which is the slug of the feature created earlier in this guide:

```tsx {{ filename: 'app/(protected)/components/ChatFAB.tsx', ins: [14, 17] }}
'use client'
import { useState } from 'react'
import { ChatBubbleOvalLeftEllipsisIcon } from '@heroicons/react/24/outline'
import { useAuth } from '@clerk/nextjs'
import SubscriptionModal from './SubscriptionModal'
import ChatInterface from './ChatInterface'

interface ChatFABProps {
  articleId: string
  articleContent: string
}

export default function ChatFAB({ articleId, articleContent }: ChatFABProps) {
  const { has } = useAuth()
  const [open, setOpen] = useState(false)

  const canUseAi = has?.({ feature: 'ai_assistant' })

  return (
    <>
      {/* FAB */}
      <button
        onClick={() => setOpen(true)}
        className="fixed right-4 bottom-12 z-50 rounded-full bg-blue-600 p-4 text-white shadow-lg hover:bg-blue-700 focus:outline-none"
        style={{ display: open ? 'none' : 'block' }}
        aria-label="Open Chat"
      >
        <ChatBubbleOvalLeftEllipsisIcon className="h-7 w-7" />
      </button>

      {/* Subscription Modal */}
      {open && !canUseAi && <SubscriptionModal onClose={() => setOpen(false)} />}

      {/* Chatbox */}
      {open && canUseAi && (
        <ChatInterface
          onClose={() => setOpen(false)}
          articleId={articleId}
          articleContent={articleContent}
        />
      )}
    </>
  )
}
```

As a best practice, we also want to protect any backend API calls that are used by the chat feature. The `has` function can also be used on server-side code as well:

```ts {{ filename: 'src/app/api/chat/route.ts', ins: [3, 7, 10] }}
import { CoreMessage, generateText } from 'ai'
import { openai } from '@ai-sdk/openai'
import { auth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function POST(req: Request) {
  const { has } = await auth()

  if (!has({ feature: 'ai_assistant' })) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 403 })
  }

  const { messages }: { messages: CoreMessage[] } = await req.json()

  const { response } = await generateText({
    model: openai('gpt-4'),
    system: 'You are a helpful assistant. Format all responses as markdown.',
    messages,
  })

  return NextResponse.json({ messages: response.messages })
}
```

When a user is subscribed, they are then able to access the AI chat:

Users can also manage their plan directly from the [`<UserButton />`](/docs/components/user/user-button) component in the new Billing tab:

![The UserButton component with the Billing tab open](./6-user_button.png)

## Conclusion

[Clerk Billing](/billing) takes all the usual friction out of implementing subscriptions—no need to wire up your own Stripe forms, manage customer data, or create custom logic for checking user plans. It's fully integrated into your authentication layer, shares the same DX principles as the rest of Clerk, and gets you from “idea” to “monetized” in record time.

Whether you're just validating a pricing model or launching a full-featured SaaS product, Clerk Billing is built to get you there faster, with fewer moving parts.

---

# Getting started with Clerk Billing
URL: https://clerk.com/blog/intro-to-clerk-billing.md
Date: 2025-05-14
Category: Company
Description: Learn how to build a complete billing experience with Clerk and Stripe, from subscriptions and usage-based pricing to role-based access—no custom UI or webhooks required.

In this episode of Stripe Developer Office Hours, Clerk CTO and co-founder Braden Sidoti shares how you can build a complete billing experience—without webhooks, custom UIs, or Stripe session management. Instead of abstracting Stripe Billing, Clerk connects directly to your Stripe account: Stripe handles payments, and Clerk takes care of the user interface, entitlement logic, and session-aware billing flows.

You'll learn how to set up subscriptions, usage-based pricing, and org-level billing using Clerk's prebuilt components and APIs. Braden also walks through how Clerk supports role-based access, secure upgrades, and customer self-service, all tightly integrated with your existing auth layer.

The conversation also touches on why Clerk approaches infrastructure this way, how to go from prototype to production without glue code, and how tying billing to identity can simplify everything from user onboarding to plan upgrades. If you're looking to ship payments faster and with less complexity, this is a blueprint worth exploring.

### Try Clerk Billing Today

Clerk Billing works in every [country supported by Stripe](https://stripe.com/global) and syncs directly with your existing Clerk application.

---

# Multi-tenant analytics with Tinybird and Clerk
URL: https://clerk.com/blog/tinybird-and-clerk.md
Date: 2025-05-02
Category: Company
Description: How to use Clerk's Tinybird JWT template to secure Tinybird APIs for fast, easy, and secure user-facing analytics in your multi-tenant application.

When you run analytics for internal use, you often don't think much about role-based access control or multi-tenancy. You just connect a BI tool to your database or data warehouse and start running some queries.

But when you're serving analytics to your end users in your product or application, then you have to think about multi-tenancy, rate limiting, and access control down to the database level.

In traditional databases, this can be pretty challenging. It's why products like Clerk are popular; they abstract the complexities of auth and access control, typically to the transactional database that stores information about users, their IDs, and their metadata.

Adding real-time, user-facing analytics to the mix presents some additional challenges. Using Clerk JWT templates and Tinybird real-time analytics APIs with row-level security policies addresses those complexities.

Here's what you'll learn in this post:

1. How Tinybird APIs are secured using static tokens or JWTs
2. How to use the Clerk JWT template for Tinybird
3. How to modify Tinybird API definitions to support Clerk JWTs
4. How to create a React context provider for auth to Tinybird APIs
5. How to update Clerk Middleware to set the token

> \[!NOTE]
> Everything covered below can be gleaned from the [open source Tinybird Clerk JWT template](https://www.tinybird.co/templates/clerk-jwt).

## Getting familiar with Tinybird

[Tinybird](https://www.tinybird.co/) is an analytics backend for your application. You use Tinybird to build [real-time data APIs](https://www.tinybird.co/docs/publish/api-endpoints) over large amounts of data such as logs, event streams, or other time series data. Tinybird gives you the tooling and infrastructure to store, query, and serve analytics and metrics to end users of your application without having to fuss with the complexities of a real-time analytics database.

And when it comes to authentication and multi-tenancy, Tinybird offers some nice perks: Every API you build with Tinybird can be secured by either static tokens or [JSON Web Tokens (JWTs)](https://www.tinybird.co/docs/forward/get-started/administration/auth-tokens#json-web-tokens-jwts). Within those tokens, you can define security policies that limit access based on user metadata.

For example, here are three Tinybird JWTs with claims that limit access at the user, team, or organization level. You'll notice that these look almost identical, but with the `fixed_params` object modified to support the security policy we want to implement.

**User level**

```json
{
  "workspace_id": "31048b76-52e8-497b-90a4-0c6a5513920d",
  "name": "user_123_jwt",
  "exp": 123123123123,
  "scopes": [
    {
      "type": "PIPE:READ",
      "resource": "my_api_endpoint",
      "fixed_params": {
        "user_id": "user123"
      }
    }
  ]
}
```

**Team level**

```json
{
  "workspace_id": "31048b76-52e8-497b-90a4-0c6a5513920d",
  "name": "team_abc_jwt",
  "exp": 123123123123,
  "scopes": [
    {
      "type": "PIPE:READ",
      "resource": "my_api_endpoint",
      "fixed_params": {
        "team_id": "team_abc"
      }
    }
  ]
}
```

**Organization level**

```json
{
  "workspace_id": "31048b76-52e8-497b-90a4-0c6a5513920d",
  "name": "org_acme_jwt",
  "exp": 123123123123,
  "scopes": [
    {
      "type": "PIPE:READ",
      "resource": "my_api_endpoint",
      "fixed_params": {
        "organization_id": "org_acme"
      }
    }
  ]
}
```

Tinybird APIs are defined using SQL queries (called "pipes" in Tinybird parlance), extended with a [Jinja](https://jinja.palletsprojects.com/en/stable/)-like templating functions to add [advanced logic](https://www.tinybird.co/docs/cli/advanced-templates) or [query parameters](https://www.tinybird.co/docs/forward/work-with-data/query-parameters).

For example, consider the pipe called `my_api_endpoint` referenced in the above JWTs, which might look this:

```sql
SELECT
    toStartOfDay(timestamp) AS day,
    sum(some_number) AS total
FROM app_events
WHERE 1
{% if defined(user_id) %}
    AND user_id = {{String(user_id)}}
{% elif defined(team_id) %}
    AND team_id = {{String(team_id)}}
{% else %}
    AND organization_id = {{String(organization_id)}}
{% end %}
```

This pipe uses Tinybird's templating language to define three query parameters: `user_id`, `team_id`, and `organization_id` for the API endpoint. When supplied in the request, those parameters will trigger the query to filter.

For example, the following request would trigger a query against the database filtering only by events belonging to `user123` and return the response:

```bash
curl -d https://api.tinybird.co/v0/pipes/my_api_endpoint?user_id=user123&token=<static_token>
```

Of course, this is not a particularly secure implementation. We're passing both the `user_id` and the static token in the URL. If we were making a request directly from the browser, this would be insecure; the token would be compromised, and the `user_id` could easily be modified to access another user's data.

JWTs solve this for us. They're not stored server-side, so they're less likely to leak. They're validated by the backend service using a secret key and contain an embedded expiration time. The JWT contains data about the requesting party and is passed to the server in the request headers. Nothing gets exposed, the data returned is properly scoped, everybody wins.

Let's see how to use Clerk's JWT templates to secure a Tinybird API.

## Using Clerk JWTs to secure Tinybird endpoints

Everything I share below references the [Tinybird Clerk JWT template](https://www.tinybird.co/templates/clerk-jwt), which includes an open-source code example, video tutorial, and live demo. Feel free to go straight there and follow the guide, or follow along here.

### Setting up the Clerk JWT template

Go to the Clerk dashboard, and select **Configure** > **JWT Template**. Select the **Tinybird JWT template**.

![The Create Jwt Template modal in the Clerk dashboard](./image1.png)

Tinybird JWTs must be signed using the admin token for the workspace where the Tinybird resources are hosted. In Clerk, make sure to enable **Custom signing key** with **HS256** signing algorithm, and paste in the Workspace admin token:

![A screenshot of the Tinybird Clerk JWT template in the Clerk dashboard](./image2.png)

You can also set an optional token lifetime. Tinybird's API endpoints will return a 403 if requested with an expired token.

Modify the claims as needed for your application:

```json
{
  "name": "frontend_jwt",
  "scopes": [
    {
      "type": "PIPES:READ",
      "resource": "<YOUR-TINYBIRD-PIPE-NAME>",
      "fixed_params": {
        "organization_id": "{{org.slug}}",
        "user_id": "{{user.id}}"
      }
    }
  ],
  "workspace_id": "<YOUR-TINYBIRD-WORKSPACE-ID>"
}
```

The example above uses the Clerk shortcodes `org.slug` and `user.id` to reference the unique identifiers for the organization and user stored in Clerk. These get passed to the Tinybird resources secured by the JWT.

### Bonus: Rate limiting

Rate limiting can be an important part of multi-tenant architectures to prevent one or a few users from monopolizing resources. Tinybird JWTs support rate limiting through a `limits` claim:

```json {{ prettier: false }}
{
    "limits": {
        "rps": 10
    },
    …
}
```

When you specify a `limits.rps` field in the payload of the JWT, Tinybird uses the name specified in the payload of the JWT to track the number of requests being made. If the number of requests per second goes beyond the limit, Tinybird starts rejecting new requests and returns an "HTTP 429 Too Many Requests" error.

### Configuring your Tinybird APIs

The only thing you need to do is double-check (or update) your Tinybird APIs and ensure logic exists to filter on the passed parameters. This logic is customizable so you can handle requests in a way that makes sense for your use case. In a multi-tenant architecture, it often makes sense to filter by default at the organization level, making an `organization_id` required in the endpoint, then optionally adding user-level filtering for resources where a user might need to see their specific data:

```sql
SELECT * FROM table
WHERE organization_id = {{String(organization_id, required=True)}}
{% if defined(user_id) %}
    AND user_id = {{UUID(user_id)}}
{% end %}
```

## Configuring a frontend-only app for multi-tenant analytics

Once you've created the JWT template in Clerk and implemented the filtering logic in your Tinybird APIs, it's relatively simple to implement the logic in your application. The example below is for a TypeScript/Next.js app, but can easily be extended to any other language or framework.

### Basic project structure

The core components of this implementation in Next.js are:

- `TinybirdProvider.tsx` - A React context provider that manages the Tinybird JWT token
- `page.tsx` - The main page component that demonstrates token usage

### TinybirdProvider component

The `TinybirdProvider` component is a React context provider that manages the authentication token needed to access Tinybird's API endpoints. This provider automatically fetches and stores the user's token (provided by Clerk on sign-in) in its state and makes it available to any component in the app through the `useTinybirdToken` hook (by using a hook, we can avoid prop drilling across components).

```tsx {{ filename: 'src/app/providers/TinybirdProvider.tsx' }}
'use client'

import { useSession } from '@clerk/nextjs'
import { createContext, useContext, useState, ReactNode, useEffect } from 'react'

interface TinybirdContextType {
  token: string
  setToken: (token: string) => void
}

const TinybirdContext = createContext<TinybirdContextType | undefined>(undefined)

export function TinybirdProvider({ children }: { children: ReactNode }) {
  const [token, setToken] = useState('')
  const { session } = useSession()

  useEffect(() => {
    if (!session) return

    async function populateToken() {
      const token = await session?.getToken({ template: 'tinybird' })
      if (!token) return
      setToken(token)
    }

    populateToken()
  }, [session])

  return <TinybirdContext.Provider value={{ token, setToken }}>{children}</TinybirdContext.Provider>
}

export function useTinybirdToken() {
  const context = useContext(TinybirdContext)
  if (context === undefined) {
    throw new Error('useTinybirdToken must be used within a TinybirdProvider')
  }
  return context
}
```

The provider automatically fetches the token when a user signs in and updates it when the session changes.

### Using the token in your application

To use the Tinybird token in your application, simply wrap your app with the `TinybirdProvider` and use the `useTinybirdToken` hook where needed:

```tsx {{ filename: 'src/app/layout.tsx' }}
import { TinybirdProvider } from './providers/TinybirdProvider'

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <ClerkProvider>
          <TinybirdProvider>{children}</TinybirdProvider>
        </ClerkProvider>
      </body>
    </html>
  )
}
```

In any component that needs to make Tinybird API calls, use the `useTinybirdToken` hook to get the token and make requests:

```tsx {{ filename: 'src/app/components/MyComponent.tsx', prettier: false }}
import { useTinybirdToken } from './providers/TinybirdProvider'

function MyComponent() {
  const { token } = useTinybirdToken()

  const fetchData = async () => {
    const response = await fetch('https://api.tinybird.co/v0/pipes/your_pipe.json', {
      headers: {
        Authorization: `Bearer ${token}`
      }
    })
    // Handle response
  }

  return (
    // Your component JSX
  )
}
```

This implementation provides a clean, efficient way to handle multi-tenant analytics with Clerk and Tinybird, while keeping the authentication logic on the client side. The provider automatically manages the token, and components can easily access it through the `useTinybirdToken` hook.

## Demo

Want to see how this works in practice? Check out this [live Clerk JWT demo for a Tinybird API.](https://clerk-tinybird.vercel.app/) If you want to check out the code, you can find it in [this repository](https://github.com/tinybirdco/clerk-tinybird).

## Get started

Building real-time analytics features into your application is pretty simple with Clerk and Tinybird. Just create a JWT template, add filtering parameters to your Tinybird APIs, and use Clerk middleware to set the token header in the request.

If you'd like to build a user-facing analytics MVP, [sign up for Tinybird](https://cloud.tinybird.co/signup) (it's free, no time limit) and follow the quick start. You'll be able to build your first API in a few minutes and have it secured in your application just as quickly using Clerk.

---

# How Huntr Migrated 250K Users to Clerk: A Scalable Auth Solution for Startups
URL: https://clerk.com/blog/huntr-testimonial.md
Date: 2025-05-01
Category: Testimonial
Description: Huntr shares how migrating to Clerk gave them transparent pricing, responsive support, and a developer-first experience.

*This customer testimonial from [Huntr.co](http://huntr.co/) — an AI-powered job search SaaS — details how and why they migrated over 250,000 users from their previous authentication provider to Clerk's developer-first authentication platform. The move was driven by a need for developer-first tools, transparent pricing, and responsive support that could scale with their product and team.*

## Background

In 2023, our team at Huntr hit a crossroads. We had built significant momentum, supporting over 250,000 job seekers on our [resume builder](https://huntr.co/product/ai-resume-builder) and AI-powered job search management platform. Then came the notification. Our authentication provider at the time informed us of a pending **$50,000 price increase** as we graduated from their startup plan.

As a fast-growing, bootstrapped SaaS company, that kind of jump was simply untenable. We were actively hiring and investing in growth and absorbing such a significant hit to our cash flow wasn't an option.

We realized we needed a new provider, an auth solution that wouldn't penalize our growth, understood the pace of a startup, and felt invested in our success. That's when we discovered Clerk.

## Why Huntr chose Clerk for scalable authentication

We successfully [migrated **250,000 user accounts** to Clerk](/docs/deployments/migrate-overview). Looking back, it's undeniably one of the best infrastructure decisions we've ever made.

> “Switching from \[our previous provider] to Clerk was honestly one of the best moves we've made at Huntr. As a growing SaaS, we instantly felt the difference in how Clerk supports startups like us.”
>
> *- Rennie Haylock, Founder and CEO of Huntr.co*

## Challenges with our previous auth provider

While our prior authentication provider served us reliably in our early days, challenges emerged as we scaled. Both technically and strategically, the relationship began to feel strained. Support became more distant, processes slowed down, and their pricing structure felt rigid. Critically, it seemed their focus had shifted firmly towards enterprise clients, leaving startups like ours feeling misaligned.

> “We realized we needed an auth platform that matched how we actually build—fast, iterative, and user-focused. What we had in place felt out of sync with that. So we started looking for something that felt more modern and developer-friendly.”
>
> *- Haylock*

## How Clerk supports startups - A true partnership

From our very first interaction, Clerk felt different. They weren't just another vendor; they acted like a partner invested in our success. Clerk's co-founder, Braden, personally engaged with us, taking the time to understand our specific use case. He immediately provided actionable support and pointed us to the exact documentation we needed.

> “Clerk, however, was a breath of fresh air. Braden jumped right in, quickly understanding our specific use cases and offering real, actionable support. They gave us the docs we needed and hands-on help that made our migration straightforward.”
>
> *- Haylock*

The migration itself was remarkably smooth, largely thanks to Clerk's incredible support team. What we anticipated would be a major undertaking turned out to be far more manageable. Furthermore, Clerk's [pricing](/pricing) was transparent and genuinely **startup-friendly**. They offered a long-term plan that gave us confidence, eliminating the anxiety of potential price shocks as our user base continued to grow.

> “Clerk's startup-friendly approach stood out to us. Whenever we hit technical snags during the migration, they were right there with quick, helpful responses. They even proactively offered us a clear, attractive long-term pricing structure, which was a huge relief as we scaled up.”
>
> *- Haylock*

## How Clerk earned Huntr's trust through support and transparency

Beyond the features and pricing, Clerk earned our trust through their accountability. A minor billing error occurred on their end early in our relationship. Instead of deflecting or delaying, they took immediate ownership, corrected the mistake, and issued a prompt refund without any hassle. This level of integrity solidified our confidence in them as a partner.

> “One thing that genuinely impressed us was how Clerk handled a billing hiccup. An unexpected error popped up, and they didn't waste time or make excuses—they immediately owned it, fixed it, and issued a refund without any hassle.”
>
> *- Haylock*

## Scaling with confidence: Clerk's long-term impact at Huntr

We've now been powered by Clerk for over a year. They've been instrumental in helping us scale efficiently, stay lean, and maintain the development velocity crucial for a startup. In a world increasingly driven by AI, where Huntr is focused on building infrastructure to help job seekers land jobs faster, the last thing we need is to worry about our authentication stack. Clerk handles it seamlessly.

If you're a startup founder evaluating your authentication options, Clerk truly understands the startup journey. They provide the speed, flexibility, fairness, and partnership that growing companies need.

> “Overall, Clerk gets startups. They're flexible, quick to respond, and dependable. We highly recommend Clerk to other SaaS companies looking for a true partner in authentication.”
>
> *Sam Wright, Head of Operations and Partnerships, Huntr.co*

## TL;DR

- Huntr migrated 250K user accounts from a legacy auth provider to Clerk
- Their previous provider introduced rigid pricing and slow support
- Clerk offered transparent pricing, fast dev support, and smoother developer workflows
- The switch helped Huntr scale efficiently without auth-related bottlenecks

>

---

# How to take your Clerk application to production
URL: https://clerk.com/blog/how-to-take-your-clerk-app-to-prod.md
Date: 2025-04-25
Category: Guides
Description: Learn how to launch your app with a production instance of Clerk to maximize security and user limits.

When you're building a modern web app, authentication is one of the first things you’ll want to wire up, and Clerk makes that easy and fast!

When you first create a Clerk application, we initialize a development instance of our platform help you get up and running in minutes. But when it's time to launch, you’ll need to switch to a production instance for maximum security and higher user limits. That transition comes with a few key differences and steps to follow.

In this article, you’ll learn what separates development from production, how to configure a Clerk production instance, and how to upgrade your social login providers for production use.

## What is the difference between dev and prod?

Clerk’s development instances are optimized for speed and simplicity. They let you integrate authentication and user management quickly, without worrying about configuration or infrastructure. By default, they’re set up to be as flexible as possible, allowing HTTP connections and using shared OAuth credentials across all Clerk apps.

Production instances, by contrast, prioritize security and isolation. They require HTTPS, and each instance must use its own credentials for identity providers. Rather than relying on cross-domain redirects, Clerk authenticates users directly on your domain using DNS records, reducing the risk of cross-site scripting attacks and improving user trust.

## Migrating to Production: Step by Step

The first step in going to production is securing a custom domain. This is the address where users will access your app, and it’s also how Clerk will verify the authenticity of authentication requests.

### Step 1: Create a production instance

Head to the [Clerk Dashboard](https://dashboard.clerk.com/). At the top of the page, you’ll see a toggle labeled **Development**—click it and select **Create production instance** from the dropdown menu.

![Clerk dashboard showing how to switch from a development instance to a production instance. The dropdown menu labeled “Development” is open, with a red arrow pointing to the “Create production instance” option. This step is part of promoting your app to production in Clerk.](./img1.png)

Clerk gives you the option to clone your dev settings or start with a fresh configuration. If you choose to clone, note that some sensitive settings like SSO connections, integrations, and custom paths won’t carry over. These need to be configured again for security reasons.

### Step 2: Configure DNS

Once your instance is created, Clerk will show you a checklist of remaining steps to make your production environment fully functional. Among these steps is configuring DNS records. These records tell the internet that Clerk is authorized to manage auth on your domain and are required for login, email delivery, and other user-facing features to work properly.

![Clerk production instance dashboard displaying the initial setup checklist. It highlights required actions such as setting custom OAuth2 credentials and configuring CNAME records before deployment. Metrics for total users, active users, and deployment status (frontend not deployed, backend operational) are shown below.
](./img2.png)

### Step 3: Update your API keys

With the instance and DNS in place, you’ll need to update your application with the new API keys. Each Clerk instance—development or production—has its own set of keys, located under **Configure** > **API Keys**. Production keys are prefixed with `pk_live` and `sk_live` for the publishable and secret key respectively.

![Clerk dashboard showing the API keys configuration page for a production instance. The screen displays the public "publishable key" (pk\_live\_...) for frontend use and a masked secret key under “default.” It also shows relevant deployment URLs, including the Frontend API, Backend API, and JWKS endpoint. A warning at the bottom reminds users that Clerk support will never ask for secret keys.](./img3.png)

### Step 4: Update SSO Connections

Social login is a common part of many apps, and Clerk makes it easy to integrate providers like Google, GitHub, and Discord. In development mode, Clerk uses shared OAuth credentials behind the scenes, so you don’t need to register anything manually. This saves time, especially when testing across multiple identity providers.

However, in production, each provider needs to recognize and trust *your* app specifically. That means you’ll need to register an application with each identity provider and generate your own client ID and secret. Once created, you can plug those into the Clerk dashboard for each enabled provider in **Configure** > **SSO connections**.

Clerk maintains a helpful set of [guides for each OAuth provider](/docs/authentication/social-connections/oauth), walking you through the process of registering your app and setting up redirect URIs. These are worth bookmarking, especially if your app supports multiple login options.

## Practical Example: Taking Quillmate Live

In this section, I’ll walk you through how I migrated **Quillmate**—an open source online writing platform built with Next.js, Clerk, and Supabase—from development to production.

Quillmate is deployed on Vercel, and I’ve registered `quillmate.site` with Namecheap as my domain.

> \[!NOTE]
> The order of the steps in this portion of the guide deviates slightly from what’s listed above to follow the launch checklist in the Clerk dashboard which may vary from app to app.

### Step 1: Create a production instance

I’ll start by selecting the **Create production instance** from the toolbar as described below and opting to clone my instance settings, which will enable email and Google login strategies:

![Clerk dashboard showing the “Create production instance” button in the toolbar. The “Development” toggle is selected, and a red arrow points to the button. This step is part of promoting your app to production in Clerk.](./img4.png)

When prompted, I’ll enter `quillmate.site` as the application domain and click **Create instance**:

![The Create production instance modal open with the domain quillmate.site entered in the input field. The “Create instance” button is highlighted.](./img5.png)

I’m then brought to the Overview tab of my production instance, which contains the checklist of the tasks I need to perform to finish setting up the instance.

![Clerk production instance dashboard displaying the initial setup checklist. It highlights required actions such as setting custom OAuth2 credentials and configuring CNAME records before deployment. Metrics for total users, active users, and deployment status (frontend not deployed, backend operational) are shown below.](./img6.png)

### Step 2: Configure Google SSO

Selecting any task here will bring you to the dashboard page where you can complete the task, so I’ll start by selecting **Set social connection (OAuth) credentials**. This will bring me to my list of enabled SSO connections.

![The list of enabled SSO connections for the production instance. The “Google” connection is highlighted.](./img7.png)

You’ll need to create OAuth credentials for each provider listed. I’m using only Google to demonstrate the process, so I’ll click the cog icon to open the configuration model for that provider.

In the modal, **Use custom credentials** is already enabled (since they are required for production instances). There is also a link to our docs, which describes the process of configuring Google SSO with Clerk, but I’ll also cover this process here.

![The Google SSO configuration modal open with the “Use custom credentials” option enabled. The “Client ID” and “Client Secret” fields are highlighted.](./img8.png)

To create the Google OAuth credentials, you’ll access the [Google Cloud Console](https://console.cloud.google.com/) and [create a new project](https://console.cloud.google.com/projectcreate) if you don’t already have one you want to work with. I have a project named “Quillmate Demo” that I’m using. I’ll use the menu to navigate to **APIs & Services > OAuth consent screen** and select **Get started**.

![The OAuth overivew tab in Google Cloud with Get Started button highlighted.](./img9.png)

The following screen will present a series of steps where you’ll need to populate information in each step. The fields are pretty self-explanatory, so here is how I populated mine:

1. App Information
   1. App name: Quillmate Demo
   2. User support email: My email address
2. Audience
   1. External
3. Contact Information
   1. Email address: My email address
4. Finish
   1. I agree to the Google API Services: User Data Policy.

Once I click **Create**, I’ll once again use the menu to navigate to **APIs & Services > Credentials** and select **Create credentials > OAuth client ID**.

![The API & Serviecs menu item in Google Cloud Console with the “Credentials” submenu open and the “Create credentials” button highlighted.](./img10.png)

In the Create OAuth client ID page, I’ll set the Application type to Web application and give it a name, Quillmate Demo, in my case. Under Authorized JavaScript origins, I’ll enter “[https://quillmate.site”](https://quillmate.site) since that’s the only URL used to access this demo, but if you use more than one URL, you’ll need to enter them all here. Finally, for Authorized redirect URIs, I’ll paste in the value displayed in the Clerk dashboard.

The final version of the screen looks like so:

![The Create OAuth client ID page in Google Cloud Console.](./img11.png)

Once I click **Create** at the bottom of the screen, I’ll receive my client ID and secret:

![The OAuth client created modal](./img12.png)

> \[!NOTE]
> Your client secret should be protected like any secret, as anyone who obtains it can impersonate your application to steal user credentials.

Then I can copy those values into the Clerk dashboard and click **Update** to complete the configuration.

![The OAuth client ID and secret copied into the Clerk dashboard.](./img13.png)

### Step 3: Configure DNS

To complete the setup in the Clerk dashboard, I’ll head back to the **Overview** tab and select **Finish your setup** from the list of tasks.

![The Finish your setup task in the Clerk dashboard.](./img14.png)

This will redirect me to the Domains section, where five records will need to be created in my DNS provider to point the necessary subdomains to Clerk for user management, as well as configure email security so we can send messages on behalf of your domain. Every provider is going to be different, but the following image represents the entered records on my registrar:

![DNS records for quillmate.site in my DNS provider.](./img15.png)

Once entered, I’ll head back to the Clerk dashboard and click Validate configuration near the top of the page, which will prompt us to check the DNS records:

![The Validate configuration button in the Clerk dashboard.](./img16.png)

Once verified, Clerk will generate the SSL certificates required for a secure connection to our services.

> \[!NOTE]
> Setting DNS records can take time to propagate, sometimes up to 24 hours in rare circumstances.

### Step 4: Updating the domain and API keys in Vercel

As mentioned earlier, I am hosting Quillmate on Vercel. In this section, I’ll set the custom domain with Vercel as well as update the Clerk API keys being used to point to the production instance of the Clerk application.

I have already deployed the development version of my application to Vercel, so I’ll start by selecting it from the list of applications I’m hosting. I’ll then navigate to **Settings > Domains** and click **Add**.

![The Domains section in Vercel with the “Add” button highlighted.](./img17.png)

Then I’ll enter the domain `quillmate.site` and click **Add Domain**.

![The Add Domain modal in Vercel with the domain quillmate.site entered in the input field. The “Add Domain” button is highlighted.](./img18.png)

Once added, I’ll need to update my DNS again to match what Vercel expects, specifically pointing the root of the domain to 76.76.21.21.

![The Domain configuration page in Vercel for quillmate.site.](./img19.png)

Once added to my DNS configuration, I can refresh the domain in Vercel to verify the configuration and generate the SSL certificates to access my site at `https://quillmate.site`.

Now that the application is accessible at my domain, I can update the API keys. These are located in the Clerk Dashboard under **Configure > API Keys**. Since I’m using Next.js, I’ll select that option from *Quick Copy* and then click the **Copy** button.

![The API keys configuration page in the Clerk dashboard with the "Next.js” option selected and the “Copy” button highlighted.](./img20.png)

Back in the Vercel dashboard, I’ll navigate to **Settings > Environment Variables** to access the current environment variables. I’ll need to modify the `CLERK_SECRET_KEY` and `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` variables to only work in non-production environments. To do this, I’ll click the three dots next to each variable and select **Edit**. I can then edit the **Environments,** uncheck the **Production** environment, and click **Save**.

![The Environment Variables section in Vercel.](./img21.png)

Once this is done for both variables, I’ll scroll up to the section where I can add new variables. Using the **Environments** drop-down once more, I can uncheck all but the **Production** environment, paste in the copied values from the Clerk dashboard, and click **Save**.

![The Environment Variables section in Vercel with the new variables added.](./img22.png)

> \[!NOTE]
> For added security, you may consider adding the CLERK\_SECRET\_KEY variable separately and setting it as a Sensitive variable to prevent team members from reading

Once I save, I’ll get the option to **redeploy** my application, which is required for the new variables to take effect.

## Conclusion

Moving your Clerk app to production is more than just flipping a flag—it’s about setting up a secure and trustworthy authentication layer that runs under your domain. Development mode is perfect for quick iteration, but production mode ensures that users have a seamless and secure experience.

By creating a production instance, updating your DNS, configuring your own OAuth credentials, and deploying with the right keys, you’ll have a secure, production-ready app with Clerk that creates a great experience for your users.

> \[!TIP]
> Learn more about taking your Clerk app to production [in our docs](/docs/deployments/overview).

---

# A practical guide to testing Clerk Next.js applications
URL: https://clerk.com/blog/testing-clerk-nextjs.md
Date: 2025-04-11
Category: Guides
Description: An example-packed guide to writing effective tests for Clerk applications, covering everything from integration testing with React Testing Library to end-to-end testing using Playwright.

We understand that writing tests isn't the most exciting part of development. That's why they are often shelved as "tech debt" or pushed to the bottom of the priority list. But it's not just about motivation - writing good tests is *hard*.

You might wonder:

- What do I test?
- How do I test it?
- Should I write integration tests or end-to-end tests?
- How do I mock Clerk?

This post addresses these challenges directly to help you develop a meaningful testing strategy for your Next.js application using Clerk.

By the end, you'll be equipped to test critical authentication flows by mocking Clerk's API in integration tests.

You'll also learn how to incorporate end-to-end tests and simulate real-world user interactions with your application to make sure that your application works (and continues to work) as it should in production.

## Meet Pup Party

This post demonstrates how to add comprehensive tests to a sample application called Pup Party.

Pup Party allows dog owners to rate and discover dog-friendly cafes and restaurants. Users can evaluate venues as "Pup-approved" or "Pup-fail" based on criteria such as dog-friendliness, ambiance, and the quality of dog treats.

To follow along and implement tests step-by-step, download the starter code and follow the set up instructions [here](https://github.com/bookercodes/testing-clerk-nextjs-apps-example).

Alternatively, you can study the complete source code, including tests, [here](https://github.com/bookercodes/testing-clerk-nextjs-apps-example/tree/finished).

## Choosing the right testing approach

Before we dive in, it's important to take a moment to think about what exactly you should be testing and the types of tests that will be most effective. This planning step is crucial, as it guides you in creating tests that are not only meaningful but also efficient to run and maintain.

I've seen teams fall into three common traps:

1. Over-relying on unit tests that focus too much on internals
2. Struggling to scale and manage integration tests effectively due to a reliance on brittle test data and overly complex test logic
3. Over-investing in E2E tests, leading to slow, flaky test suites that bog down development

So, what's the right approach?

In this post, we turn to [Kent C. Dodds](https://kentcdodds.com/) and his [testing trophy](https://kentcdodds.com/blog/the-testing-trophy-and-testing-classifications):

- Write mostly integration tests
- Supplement with well-placed E2E tests
- Don't overdo it

Following Kent's testing philosophy, this post primarily focuses on integration tests and later addresses E2E tests for critical user paths.

## Integration tests

To write integration tests, you will be using two essential tools:

- **[Jest](https://jestjs.io)**: A test runner and assertion framework to structure and execute your integration tests.
- **[React Testing Library (RTL)](https://testing-library.com/docs/react-testing-library/intro/)**: A popular testing framework for React applications that encourages testing components from the user's perspective, while still supporting the use of [Jest mocks](https://jestjs.io/docs/mock-functions) where necessary.

### Set up Jest and RTL

Start by installing these dev dependencies:

```bash {{ filename: 'Terminal' }}
npm install --save-dev jest \
  @types/jest \
  @testing-library/react \
  @testing-library/jest-dom \
  @testing-library/dom \
  @testing-library/user-event \
  jest-environment-jsdom \
  next-router-mock
```

Add a `test:jest` script to `package.json`:

```json {{ filename: './package.json', ins: [6], prettier: false }}
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "test:jest": "jest",
},
```

Add a `jest.config.ts` file to the root of your project:

```typescript {{ filename: './jest.config.ts' }}
import type { Config } from 'jest'
import nextJest from 'next/jest.js'

const createJestConfig = nextJest({
  dir: './',
})

const config: Config = {
  clearMocks: true,
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  // Map next-router-mock to the next/navigation module it mocks
  // Learn more - https://github.com/scottrippey/next-router-mock
  moduleNameMapper: {
    '^next/navigation$': 'next-router-mock',
  },
}

export default createJestConfig(config)
```

Create a `jest.setup.ts` file in the root of your project to globally import @testing-library/jest-dom, which enhances Jest with custom matchers for more intuitive and readable DOM assertions:

```typescript {{ filename: './jest.setup.ts' }}
import '@testing-library/jest-dom'
```

In the ` ./tsconfig.json` file, add `"jest"` to the `types` array:

```json {{ filename: './tsconfig.json', ins: [3] }}
{
  "compilerOptions": {
    "types": ["jest"]
  }
}
```

Finally, create a ` ./__tests__` directory in your root folder. Within this directory, add an empty `index.test.tsx` file - this is where you'll set up your mocks, configure helpers, and write your integration tests in the next section.

#### Set up your mocks and helpers

In your integration tests, you should avoid making actual network calls, including to Clerk. Network calls can introduce variability and slow down your test suite, making it harder to achieve consistent and fast test results. Instead, you should [mock](https://stackoverflow.com/a/2666006) these libraries to control their behavior in your tests and keep your integration test suite lean.

> \[!IMPORTANT]
>
> It's a best practice to avoid writing integration tests for the internals of third-party libraries like Clerk, as they already have their own tests.

Below is the code to mock @clerk/nextjs, which allows you to simulate its behavior and focus on testing your application's logic without relying on external dependencies. Additionally, a helper function called `renderWithProviders` is defined. This function takes an `isSignedIn` argument, allowing you to simulate authenticated and unauthenticated user states in your tests.

```tsx {{ filename: './__tests__/index.test.tsx' }}
import { render, screen, waitFor } from '@testing-library/react'
import { ReactNode } from 'react'
import SubmitReviewPage from '../app/submit-review/page'
import { ClerkProvider, useAuth } from '@clerk/nextjs'
import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider/next-13.5'
import { userEvent } from '@testing-library/user-event'

jest.mock('@clerk/nextjs', () => {
  const originalModule = jest.requireActual('@clerk/nextjs')
  return {
    ...originalModule,
    useAuth: jest.fn(() => ({ userId: null })),
    SignIn: () => <div data-testid="clerk-sign-in">Sign In Component</div>,
    ClerkProvider: ({ children }: { children: ReactNode }) => <div>{children}</div>,
  }
})

const TestProviders = ({
  isLoggedIn = false,
  children,
}: {
  isLoggedIn?: boolean
  children: ReactNode
}) => {
  ;(useAuth as jest.Mock).mockReturnValue({ userId: isLoggedIn ? 'user-id' : null })

  // Here, we wrap our component in the ClerkProvider and MemoryRouterProvider to provide the necessary context for our tests.
  // MemoryRouterProvider is used to mock the Next.js router,
  // which is necessary for testing components that use the router.
  return (
    <MemoryRouterProvider>
      <ClerkProvider>{children}</ClerkProvider>
    </MemoryRouterProvider>
  )
}

const renderWithProviders = (ui: ReactNode, isLoggedIn?: boolean) => {
  return render(<TestProviders isLoggedIn={isLoggedIn}>{ui}</TestProviders>)
}
```

> \[!NOTE]
>
> The details about next-router-mock are outside the scope of this post, but you can learn more about it in the library's [documentation](https://github.com/scottrippey/next-router-mock) for a deeper understanding.

Now that you have Jest and RTL configured, along with your mocks and test context provider, it's time to write some meaningful integration tests!

You will implement three integration tests for Pup Party. For each test scenario, you'll read a definition of the requirements in plain text before implementing code to programmatically test the expected behavior and protect against regressions.

### Test case 1: Unauthenticated users cannot submit a review

If an unauthenticated user tries to access the submission page, they should be redirected to the sign-in page.

Here's the test implementation, along with comments explaining each notable line.

Add it to the bottom of index.test.tsx:

```tsx {{ filename: './__tests__/index.test.tsx', ins: [[3, 31]] }}
// Add below the previous snippet at the bottom of the file

describe('Submit Review Page', () => {
  // Grouping tests related to unauthenticated user scenarios
  describe('When a user is unauthenticated', () => {
    const isLoggedIn = false

    it('redirects them to sign in when they try to access the review submission page', async () => {
      // Render the SubmitReviewPage component with the user not logged in
      renderWithProviders(<SubmitReviewPage />, isLoggedIn)

      // Wait for the sign-in element to appear, indicating a redirect to sign-in
      waitFor(
        () => {
          expect(screen.getByTestId('clerk-sign-in')).toBeInTheDocument()
        },
        { timeout: 5000 }, // Timeout after 5 seconds if the element doesn't appear
      )

      // Ensure the review submission prompt is not visible, confirming the redirect
      waitFor(
        () => {
          expect(
            screen.queryByText('Review how dog-friendly this restaurant is!'),
          ).not.toBeInTheDocument()
        },
        { timeout: 5000 }, // Timeout after 5 seconds if the element is still visible
      )
    })
  })
})
```

In this test, an unauthenticated user's experience on the `<SubmitReviewPage />` is simulated by setting ` isSignedIn` to ` false` and passing it as the second argument to our ` renderWithProviders` function.

The [`waitFor`](https://testing-library.com/docs/dom-testing-library/api-async/#waitfor) utility from React Testing Library is used to verify that the UI updates correctly after state changes, specifically ensuring the user is redirected to the sign-in page and cannot access the review submission content.

> \[!TIP]
> You can see the complete test file [here](https://github.com/bookercodes/testing-clerk-nextjs-apps-example/blob/finished/__tests__/index.test.tsx) and reference it if you're following along and need help figuring out where things go.

### Test case 2: Authenticated users can successfully submit a valid review

When a signed-in user submits valid details in the review form, the form should process successfully and then display a confirmation message.

Here's the test implementation:

```tsx {{ filename: './__tests__/index.test.tsx', ins: [[6, 37]] }}
describe('Submit Review Page', () => {
  describe('When a user is authenticated', () => {
    const isLoggedIn = true

    // Add beneath "it" function in previous snippet
    it('allows them to submit a review successfully', async () => {
      const user = userEvent.setup()

      renderWithProviders(<SubmitReviewPage />, isLoggedIn)

      expect(
        await screen.findByText('Welcome to the Dog Friendly Restaurant Reviews form!'),
      ).toBeInTheDocument()

      expect(screen.queryByTestId('clerk-sign-in')).not.toBeInTheDocument()

      const reviewInput = await screen.findByRole('textbox', {
        name: /review/i,
      })
      const ratingInput = await screen.findByRole('spinbutton', {
        name: /rating/i,
      })

      await user.click(reviewInput)
      await user.type(reviewInput, 'Great place!')
      await user.click(ratingInput)
      await user.type(ratingInput, '5')

      await user.click(screen.getByText('Submit'))

      // expect the form to be cleared
      expect(screen.getByLabelText('Review')).toHaveValue('')
      expect(screen.getByLabelText('Rating')).toHaveValue

      // expect a success toast
      expect(await screen.findByText('Form submitted successfully!')).toBeInTheDocument()
    })
  })
})
```

In the code above, `isSignedIn` is set to `true` to simulate the experience of an authenticated user accessing Pup Party.

The `userEvent` utilities are used to mimic user actions, such as clicking into a form input, typing, hitting "Submit", and viewing a success notification.

> \[!TIP]
> Use React Testing Library's selectors like [`findByRole`](https://testing-library.com/docs/queries/byrole/) and [`getByLabelText`](https://testing-library.com/docs/queries/bylabeltext/) to create more robust tests.
>
> This approach, compared to using selectors like `getElementById` or `querySelector`, avoids relying on rigid DOM structures in your queries and assertions. By focusing on the roles and labels, you reduce the likelihood of introducing test failures from UI layout changes. It also promotes the use of semantic HTML, thereby enhancing your application's accessibility.

### Test case 3: Authenticated users must submit valid reviews

Requirements for the submission form:

- When authenticated users add a rating that is less than zero, they should see a "Rating must be a number above 0" error appear next to the rating field
- When authenticated users add a written review that is less than 5 characters long, they should see a "Review must be at least 5 characters" error appear next to the review field
- If authenticated users submit the form without correcting the errors, the form fields should retain the original values along with their error messages, and an additional "Please fix the errors in the form!" will be displayed to the user

Here's the test code that checks these requirements:

```tsx {{ filename: './__tests__/index.test.tsx', ins: [[5, 41], [43, 80]] }}
describe('Submit Review Page', () => {
  describe('When a user is authenticated', () => {
    const isLoggedIn = true

    it('displays error messages when the user adds a rating that is less than 0', async () => {
      const user = userEvent.setup()

      renderWithProviders(<SubmitReviewPage />, isLoggedIn)

      expect(
        await screen.findByText('Welcome to the Dog Friendly Restaurant Reviews form!'),
      ).toBeInTheDocument()

      expect(screen.queryByTestId('clerk-sign-in')).not.toBeInTheDocument()

      // Note the use of findByRole, as well as async/await. Role lookup is based on the role attribute, which is used to describe the purpose of an element.
      // This is useful for accessibility.
      const reviewInput = await screen.findByRole('textbox', {
        name: /review/i,
      })
      const ratingInput = await screen.findByRole('spinbutton', {
        // Note: can also find by Label!
        name: /rating/i,
      })

      await user.click(reviewInput)
      await user.type(reviewInput, 'Great place!')
      await user.click(ratingInput)
      await user.type(ratingInput, '-1')

      expect(await screen.findByText('Rating must be a number above 0')).toBeInTheDocument()

      await user.click(screen.getByText('Submit'))

      // Note the use of getByLabelText to find the input field by its label text, which helps with accessibility.
      expect(screen.getByLabelText('Review')).toHaveValue('Great place!')
      expect(screen.getByLabelText('Rating')).toHaveValue(-1)

      // expect an error message
      expect(await screen.findByText('Please fix the errors in the form!')).toBeInTheDocument()
    })

    it('displays error messages when the user adds a review that is less than 5 characters', async () => {
      const user = userEvent.setup()

      renderWithProviders(<SubmitReviewPage />, isLoggedIn)

      // Note here the user of findByText based on the page's text content as viewed by the user.
      expect(
        await screen.findByText('Welcome to the Dog Friendly Restaurant Reviews form!'),
      ).toBeInTheDocument()

      // Note the use of queryByTestId to check for non-existence.
      expect(screen.queryByTestId('clerk-sign-in')).not.toBeInTheDocument()

      // Note the use of findByRole, as well as async/await.
      const reviewInput = await screen.findByRole('textbox', {
        name: /review/i,
      })
      const ratingInput = await screen.findByRole('spinbutton', {
        // Note: can also find by Label!
        name: /rating/i,
      })

      await user.click(reviewInput)
      await user.type(reviewInput, 'ok')
      await user.click(ratingInput)
      await user.type(ratingInput, '5')

      expect(await screen.findByText('Review must be at least 5 characters')).toBeInTheDocument()

      await user.click(screen.getByText('Submit'))

      // Expect the form to be cleared
      expect(screen.getByLabelText('Review')).toHaveValue('ok')
      expect(screen.getByLabelText('Rating')).toHaveValue(5)

      // Expect an error message
      expect(await screen.findByText('Please fix the errors in the form!')).toBeInTheDocument()
    })
  })
})
```

> \[!NOTE]
> While the previous examples used a single test, here you've created separate tests for each validation error case, making it easier to identify and debug specific failures.

This code simulates a user interaction where an invalid review is submitted. It sets up a user event, renders the `<SubmitReviewPage />` for an authenticated user, and then mimics user actions of submitting a valid review but an invalid rating.

The `async` and `await` syntax is used to ensure that each action and corresponding assertion happens in the correct order, as some operations are asynchronous and need to complete before the next action or assertion takes place.

### Running the integration tests

Execute your integration tests by running `test:jest`:

```bash {{ filename: 'Terminal' }}
npm run test:jest
```

This script, defined earlier, will run the tests and display the results in your terminal:

![./jest-results.png](./jest-results.png)

## End-to-end tests

Having implemented integration tests, let's turn our attention to end-to-end (E2E) tests.

> \[!NOTE]
> Integration tests focus on verifying that specific components interact correctly with each other, while E2E tests validate complete user workflows across an entire application in production-like environments.
>
> Both are necessary because integration tests provide faster, more targeted feedback about component interactions during development, while E2E tests ensure the complete system functions properly from a user's perspective before release.
>
> [Learn more](https://microsoft.github.io/code-with-engineering-playbook/automated-testing/e2e-testing/) about end-to-end tests.

Unlike integration tests that mock Clerk, E2E tests interact with Clerk directly to simulate production conditions.

To write E2E tests, you will be using two essential tools:

- **[Playwright](https://playwright.dev/)**: A powerful automation framework that allows you to perform end-to-end testing across multiple browsers. It offers comprehensive features for simulating user interactions and validating web applications.
- **[@clerk/testing](https://www.npmjs.com/package/@clerk/testing)**: This utility offers helpful tools specifically designed for testing Clerk applications.

### Set up Playwright and @clerk/testing

Start by create a new directory called `./e2e` - this is where you'll write your E2E in the upcoming section.

Install `@clerk/testing` as a dev dependency:

```bash {{ filename: 'Terminal' }}
npm install @clerk/testing --save-dev
```

Initialize and install Playwright:

```bash {{ filename: 'Terminal' }}
npm init playwright
```

Choose the following options when prompted:

```bash {{ filename: 'Terminal' }}
> Do you want to use TypeScript or JavaScript? → TypeScript
> Where to put your end-to-end tests? → e2e
> Add a GitHub Actions workflow? → N
> Install Playwright browsers? → Y
```

> \[!NOTE]
>
> `npm init playwright` automatically installs Playwright as a dev dependency and creates a default `playwright.config.ts` file

Replace the contents of `./playwright.config.ts` to configure Playwright for end-to-end testing:

```typescript {{ filename: './playwright.config.ts' }}
import { defineConfig } from '@playwright/test'

// Set the port for the server
const PORT = process.env.PORT || 3000

// Set webServer.url and use.baseURL with the location of the WebServer
// respecting the correct set port
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
  // Look for tests in the "e2e" directory
  testDir: './e2e',
  // Set the number of retries for each, in case of failure
  retries: 1,
  // Run your local dev server before starting the tests.
  webServer: {
    command: 'npm run dev',
    // Base URL to use in actions like `await page.goto('/')`
    url: baseURL,
    // Set the timeout for the server to start
    timeout: 120 * 1000,
    // Reuse the server between tests
    reuseExistingServer: !process.env.CI,
  },
  use: {
    // Base URL to use in actions like `await page.goto('/')`.
    baseURL,

    // Collect trace when retrying the failed test.
    // See https://playwright.dev/docs/trace-viewer
    trace: 'retry-with-trace',
  },
})
```

Define an additional script to run E2E tests:

```json {{ filename: './package.json', ins: [7], prettier: false }}
"scripts": {
  "dev": "next dev",
  "build": "next build",
  "start": "next start",
  "lint": "next lint",
  "test:jest": "jest",
  "test:playwright": "playwright test --ui"
},
```

To ensure your E2E tests don't inadvertently run when invoking the `test:jest` script - which could cause errors due to Playwright tests being incompatible with Jest - update `jest.config.ts` as follows:

```typescript {{ filename: './jest.config.ts', ins: [17] }}
import type { Config } from 'jest'
import nextJest from 'next/jest.js'

const createJestConfig = nextJest({
  dir: './',
})

const config: Config = {
  clearMocks: true,
  testEnvironment: 'jsdom',
  setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
  // map next-router-mock to the next/navigation module it mocks
  // learn more - https://github.com/scottrippey/next-router-mock
  moduleNameMapper: {
    '^next/navigation$': 'next-router-mock',
  },
  testPathIgnorePatterns: ['<rootDir>/e2e/', '<rootDir>/.next/', '<rootDir>/node_modules/'],
}

export default createJestConfig(config)
```

#### Enable Clerk testing tokens

Clerk [testing tokens](/docs/testing/overview#testing-tokens) allow you to bypass Clerk's bot detection mechanisms, which can otherwise block automated test requests with "bot traffic detected" errors.

The @clerk/testing library makes accessing Clerk testing tokens easy by automatically obtaining one when your test suite starts. It then offers the `setupClerkTestingToken` function, which injects the token, enabling your tests to bypass Clerk's bot detection mechanisms without any hassle. The `clerk.signIn` method internally uses the `setupClerkTestingToken` helper, so there's no need to call it separately when using this method.

> \[!NOTE]
> While it's helpful to understand why this is necessary, @clerk/testing and the Playwright integration abstract away the details, so you don't have to manage tokens manually.

To configure Playwright with Clerk, create `./e2e/global.setup.ts` and call the `clerkSetup()` function:

```typescript {{ filename: './e2e/global.setup.ts' }}
import { clerkSetup } from '@clerk/testing/playwright'
import { test as setup } from '@playwright/test'

// Setup must be run serially, this is necessary if Playwright is configured to run fully parallel: https://playwright.dev/docs/test-parallel
setup.describe.configure({ mode: 'serial' })

setup('global setup', async ({}) => {
  await clerkSetup()
})
```

Next, make sure `global.setup.ts` is called from `./playwright.config.ts`:

```typescript {{ filename: './playwright.config.ts', del: [1], ins: [2, [34, 49]] }}
import { defineConfig } from '@playwright/test'
import { defineConfig, devices } from '@playwright/test'

// Set the port for the server
const PORT = process.env.PORT || 3000

// Set webServer.url and use.baseURL with the location of the WebServer
// respecting the correct set port
const baseURL = `http://localhost:${PORT}`

export default defineConfig({
  // Look for tests in the "e2e" directory
  testDir: './e2e',
  // Set the number of retries for each, in case of failure
  retries: 1,
  // Run your local dev server before starting the tests.
  webServer: {
    command: 'npm run dev',
    // Base URL to use in actions like `await page.goto('/')`
    url: baseURL,
    // Set the timeout for the server to start
    timeout: 120 * 1000,
    // Reuse the server between tests
    reuseExistingServer: !process.env.CI,
  },
  use: {
    // Base URL to use in actions like `await page.goto('/')`
    baseURL,

    // Collect trace when retrying the failed test.
    // See https://playwright.dev/docs/trace-viewer
    trace: 'retry-with-trace',
  },

  // Configure projects for major browsers
  projects: [
    {
      name: 'global setup',
      testMatch: /global\.setup\.ts/,
    },
    {
      name: 'Main tests',
      testMatch: /user-submits-review\.spec\.ts/,
      use: {
        ...devices['Desktop Chrome'], // or your browser of choice
      },
      dependencies: ['global setup'],
    },
  ],
})
```

#### Create a test Clerk user

Since E2E tests interact with the Clerk API and require user authentication, you must create a real Clerk user and supply your test runner with the user's credentials. These credentials allow your tests to sign in, simulate an authenticated user state, and ensure everything functions as expected in a real-world scenario.

Create a test user through the Clerk dashboard:

![./create-user.png](./create-user.png)

Add the username and password to `.env.local`. Also add your Clerk application's `SECRET_KEY` and `PUBLISHABLE_KEY` if you haven't already:

```bash {{ filename: './env.local' }}
E2E_CLERK_USER_USERNAME=xxxxxxxxx
E2E_CLERK_USER_PASSWORD=xxxxxxxxx
CLERK_SECRET_KEY=xxxxxxxxx
CLERK_PUBLISHABLE_KEY=xxxxxxxxx
```

> \[!IMPORTANT]
> Replace `xxxxxxxxx` with your actual credentials.
>
> If you have enabled usernames for your Clerk application, set `E2E_CLERK_USER_USERNAME` to a username. If your Clerk application does not support usernames, set it to an email address instead.

Next, update your `playwright.config.ts` file to load environment variables such that they can be accessed from your tests:

```typescript {{ filename: './playwright.config.ts', ins: [3, 4, 6, 7] }}
// At the top of the file
import { defineConfig, devices } from '@playwright/test'
import dotenv from 'dotenv'
import path from 'path'

// Read the .env.local file and set the environment variables
dotenv.config({ path: path.resolve(__dirname, '.env.local') })
```

You now have everything in place to write an end-to-end test.

### Test case: Users can authenticate and successfully submit a review

This test verifies a critical user flow in a Clerk-authenticated application: signing in, submitting a review, and signing out.

Since this is an end-to-end test, it will cover multiple points in the system, ensuring that real user interactions work as expected.

Create `./e2e/user-submits-review.spec.ts` and paste the following:

```typescript {{ filename: './e2e/user-submits-review.spec.ts' }}
import { test, expect } from '@playwright/test'
import { clerk } from '@clerk/testing/playwright'

test('user can sign in, submit a review and sign out', async ({ page }) => {
  await page.goto('/sign-in')

  // Clerk's signIn utility uses setupClerkTestingToken() under the hood, so no reason to call it separately
  await clerk.signIn({
    page,
    signInParams: {
      strategy: 'password',
      identifier: process.env.E2E_CLERK_USER_USERNAME!,
      password: process.env.E2E_CLERK_USER_PASSWORD!,
    },
  })

  await page.goto('/submit-review')

  await expect(page).toHaveURL('/submit-review')

  // Fill in the review form
  await page.getByLabel(/review/i).fill('Had a great experience!')
  await page.getByLabel(/rating/i).fill('5')
  await page.getByRole('button', { name: /submit/i }).click()

  await expect(page.getByText(/form submitted successfully/i)).toBeVisible()

  // Clerk's signOut utility uses setupClerkTestingToken() under the hood, so no reason to call it separately
  await clerk.signOut({ page })

  expect(page).toHaveURL('/')
})
```

> \[!TIP]
>
> The `clerk.signIn()` function automatically uses the `setupClerkTestingToken()` helper, so there's no need to call it manually.

The test starts by navigating to the sign-in page and using Clerk's `signIn` utility to authenticate a test user.

Once signed in, the test redirects to the review submission page, where it completes and submits a form, then verifies a success message. Finally, it signs out and confirms redirection to the home page.

Running this test in the future will help catch any errors introduced by code changes. If a change breaks the functionality, the test will fail, alerting you to the issue. If the test passes, you can be confident that this flow in your application is working as it should.

### Running the end-to end tests

Run `test:playwright` to execute your end-to-end tests:

```bash {{ filename: 'Terminal' }}
npm run test:playwright
```

The result:

![./playwright-results.png](./playwright-results.png)

## Conclusion

In the introduction, key questions were posed about testing in Clerk applications:

- What should be tested?
- How should it be tested?
- Should integration or end-to-end tests be used?
- How can Clerk be effectively mocked?

This post has provided comprehensive answers to these questions, guiding you through the process of developing a robust testing strategy against a practical application.

Equipped with this knowledge, you now have the tools to confidently implement a balanced testing strategy for any appliation using Clerk for [Next.js authentication](/nextjs-authentication).

For more detailed guidance on testing Clerk applications, be sure to check out the [Clerk documentation on testing](/docs/testing/overview).