Complete Guide to Clerk Authentication in Next.js 15 with Organizations and RBAC

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

Introduction

Authentication is one of the most critical features of any web application, yet building it from scratch is complex, error-prone, and time-consuming. Clerk has emerged as the leading authentication platform for Next.js applications, offering a complete solution that goes far beyond simple sign-in and sign-up flows.

Unlike NextAuth.js or custom JWT implementations, Clerk provides a fully managed service with embeddable UI components, multi-factor authentication, organization management, role-based access control (RBAC), and webhook-driven event handling — all out of the box.

In this tutorial, you will build a complete multi-tenant SaaS application with Clerk and Next.js 15, implementing authentication, organization switching, role-based permissions, and protected API routes.

What You Will Learn

  • Set up Clerk in a Next.js 15 App Router project
  • Implement sign-up, sign-in, and user profile flows
  • Protect routes with Clerk middleware
  • Create and manage organizations (multi-tenancy)
  • Implement role-based access control (RBAC)
  • Build protected API routes
  • Handle webhooks for user and organization events
  • Customize Clerk components to match your design

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ installed on your machine
  • Basic knowledge of React and TypeScript
  • Familiarity with Next.js App Router
  • A Clerk account (free tier available at clerk.com)
  • A code editor (VS Code recommended)

What You Will Build

You will build a multi-tenant project management dashboard where:

  • Users can sign up and sign in with email, Google, or GitHub
  • Users can create and join organizations
  • Organization admins can manage members and assign roles
  • Different roles (admin, member, viewer) see different UI elements
  • API routes are protected based on authentication and roles

Step 1: Create the Next.js Project

Start by creating a fresh Next.js 15 project with TypeScript and Tailwind CSS:

npx create-next-app@latest clerk-saas-app --typescript --tailwind --eslint --app --src-dir
cd clerk-saas-app

Install the Clerk Next.js SDK:

npm install @clerk/nextjs

Step 2: Configure Clerk

Create a Clerk Application

  1. Go to clerk.com and sign in to your dashboard
  2. Click Create application
  3. Name it "SaaS Dashboard"
  4. Enable the sign-in methods you want (Email, Google, GitHub)
  5. Copy your API keys

Set Up Environment Variables

Create a .env.local file in your project root:

NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_your_publishable_key
CLERK_SECRET_KEY=sk_test_your_secret_key
 
# Clerk redirect URLs
NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up
NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/dashboard
NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/dashboard

Replace the placeholder keys with your actual Clerk API keys from the dashboard.

Step 3: Add the Clerk Provider

Wrap your application with the ClerkProvider in your root layout. This provides authentication context to all components:

// src/app/layout.tsx
import type { Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
import { Inter } from "next/font/google";
import "./globals.css";
 
const inter = Inter({ subsets: ["latin"] });
 
export const metadata: Metadata = {
  title: "SaaS Dashboard",
  description: "Multi-tenant SaaS with Clerk authentication",
};
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className={inter.className}>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Step 4: Set Up Middleware for Route Protection

Clerk's middleware is the backbone of route protection. Create a middleware.ts file in your project root:

// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
 
const isPublicRoute = createRouteMatcher([
  "/",
  "/sign-in(.*)",
  "/sign-up(.*)",
  "/api/webhooks(.*)",
]);
 
export default clerkMiddleware(async (auth, request) => {
  if (!isPublicRoute(request)) {
    await auth.protect();
  }
});
 
export const config = {
  matcher: [
    // Skip Next.js internals and static files
    "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)",
    // Always run for API routes
    "/(api|trpc)(.*)",
  ],
};

This configuration ensures that:

  • Public routes (home, sign-in, sign-up, webhooks) are accessible without authentication
  • All other routes require the user to be signed in
  • Next.js static assets are excluded from middleware processing

Step 5: Create Authentication Pages

Sign-In Page

// src/app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
 
export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <SignIn
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "shadow-lg border border-gray-200",
          },
        }}
      />
    </div>
  );
}

Sign-Up Page

// src/app/sign-up/[[...sign-up]]/page.tsx
import { SignUp } from "@clerk/nextjs";
 
export default function SignUpPage() {
  return (
    <div className="flex min-h-screen items-center justify-center bg-gray-50">
      <SignUp
        appearance={{
          elements: {
            rootBox: "mx-auto",
            card: "shadow-lg border border-gray-200",
          },
        }}
      />
    </div>
  );
}

The [[...sign-in]] and [[...sign-up]] catch-all route segments allow Clerk to handle multi-step authentication flows like email verification and MFA within the same route.

Step 6: Build the Dashboard Layout

Create a protected dashboard layout with a navigation bar that shows user information and organization switching:

// src/app/dashboard/layout.tsx
import {
  OrganizationSwitcher,
  UserButton,
} from "@clerk/nextjs";
import Link from "next/link";
 
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="min-h-screen bg-gray-50">
      <nav className="border-b bg-white px-6 py-3">
        <div className="mx-auto flex max-w-7xl items-center justify-between">
          <div className="flex items-center gap-6">
            <Link href="/dashboard" className="text-xl font-bold">
              SaaS Dashboard
            </Link>
            <div className="flex items-center gap-4">
              <Link
                href="/dashboard"
                className="text-sm text-gray-600 hover:text-gray-900"
              >
                Projects
              </Link>
              <Link
                href="/dashboard/members"
                className="text-sm text-gray-600 hover:text-gray-900"
              >
                Members
              </Link>
              <Link
                href="/dashboard/settings"
                className="text-sm text-gray-600 hover:text-gray-900"
              >
                Settings
              </Link>
            </div>
          </div>
          <div className="flex items-center gap-4">
            <OrganizationSwitcher
              appearance={{
                elements: {
                  rootBox: "flex items-center",
                  organizationSwitcherTrigger:
                    "rounded-md border px-3 py-1.5 text-sm",
                },
              }}
              afterCreateOrganizationUrl="/dashboard"
              afterSelectOrganizationUrl="/dashboard"
            />
            <UserButton afterSignOutUrl="/" />
          </div>
        </div>
      </nav>
      <main className="mx-auto max-w-7xl px-6 py-8">{children}</main>
    </div>
  );
}

The OrganizationSwitcher component lets users create new organizations and switch between them. The UserButton provides profile management and sign-out functionality.

Step 7: Display User and Organization Data

Create the main dashboard page that shows data based on the current user and organization:

// src/app/dashboard/page.tsx
import { auth, currentUser } from "@clerk/nextjs/server";
 
export default async function DashboardPage() {
  const { orgId, orgRole } = await auth();
  const user = await currentUser();
 
  return (
    <div className="space-y-8">
      <div>
        <h1 className="text-3xl font-bold">
          Welcome back, {user?.firstName || "User"}
        </h1>
        <p className="mt-1 text-gray-500">
          Here is an overview of your workspace.
        </p>
      </div>
 
      {orgId ? (
        <div className="rounded-lg border bg-white p-6 shadow-sm">
          <h2 className="text-lg font-semibold">Current Organization</h2>
          <div className="mt-4 grid grid-cols-2 gap-4">
            <div>
              <p className="text-sm text-gray-500">Organization ID</p>
              <p className="font-mono text-sm">{orgId}</p>
            </div>
            <div>
              <p className="text-sm text-gray-500">Your Role</p>
              <p className="font-medium capitalize">
                {orgRole?.replace("org:", "")}
              </p>
            </div>
          </div>
        </div>
      ) : (
        <div className="rounded-lg border border-dashed border-gray-300 bg-white p-8 text-center">
          <h2 className="text-lg font-semibold">No Organization Selected</h2>
          <p className="mt-2 text-gray-500">
            Create or join an organization to start collaborating with your
            team.
          </p>
        </div>
      )}
 
      <div className="grid grid-cols-1 gap-6 md:grid-cols-3">
        <StatCard title="Projects" value="12" />
        <StatCard title="Team Members" value="8" />
        <StatCard title="Active Tasks" value="34" />
      </div>
    </div>
  );
}
 
function StatCard({ title, value }: { title: string; value: string }) {
  return (
    <div className="rounded-lg border bg-white p-6 shadow-sm">
      <p className="text-sm text-gray-500">{title}</p>
      <p className="mt-2 text-3xl font-bold">{value}</p>
    </div>
  );
}

Notice how auth() is called on the server side to get the current organization ID and role. This data is available without any client-side JavaScript.

Step 8: Enable Organizations in Clerk

Before implementing organization features, enable them in your Clerk dashboard:

  1. Go to Organizations in the Clerk dashboard sidebar
  2. Click Enable organizations
  3. Under Roles, you will see default roles: org:admin and org:member
  4. Add a custom role: org:viewer with limited permissions

Define Custom Permissions

In the Clerk dashboard under Organizations, then Roles and Permissions:

Create these permissions:

  • org:projects:create — Create new projects
  • org:projects:read — View projects
  • org:projects:update — Edit projects
  • org:projects:delete — Delete projects
  • org:members:manage — Manage organization members

Assign permissions to roles:

PermissionAdminMemberViewer
org:projects:createYesYesNo
org:projects:readYesYesYes
org:projects:updateYesYesNo
org:projects:deleteYesNoNo
org:members:manageYesNoNo

Step 9: Implement Role-Based Access Control

Server-Side Permission Checks

Create a utility function for checking permissions on the server:

// src/lib/auth.ts
import { auth } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
 
export async function requireAuth() {
  const session = await auth();
 
  if (!session.userId) {
    redirect("/sign-in");
  }
 
  return session;
}
 
export async function requireOrg() {
  const session = await requireAuth();
 
  if (!session.orgId) {
    redirect("/dashboard");
  }
 
  return session;
}
 
export async function checkPermission(permission: string): Promise<boolean> {
  const session = await auth();
 
  if (!session.userId || !session.orgId) {
    return false;
  }
 
  const hasPermission = await session.has({ permission });
  return hasPermission;
}

Role-Based UI Components

Create a component that conditionally renders content based on the user's role:

// src/components/role-gate.tsx
"use client";
 
import { useAuth } from "@clerk/nextjs";
 
type RoleGateProps = {
  children: React.ReactNode;
  allowedRoles: string[];
  fallback?: React.ReactNode;
};
 
export function RoleGate({
  children,
  allowedRoles,
  fallback = null,
}: RoleGateProps) {
  const { orgRole } = useAuth();
 
  if (!orgRole || !allowedRoles.includes(orgRole)) {
    return fallback;
  }
 
  return children;
}

Using the Role Gate

// src/app/dashboard/projects/page.tsx
import { auth } from "@clerk/nextjs/server";
import { RoleGate } from "@/components/role-gate";
 
export default async function ProjectsPage() {
  const { orgId } = await auth();
 
  if (!orgId) {
    return (
      <div className="text-center py-12">
        <p className="text-gray-500">Select an organization to view projects.</p>
      </div>
    );
  }
 
  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h1 className="text-2xl font-bold">Projects</h1>
        <RoleGate allowedRoles={["org:admin", "org:member"]}>
          <button className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700">
            New Project
          </button>
        </RoleGate>
      </div>
 
      <div className="grid gap-4">
        <ProjectCard
          title="Website Redesign"
          status="In Progress"
          members={4}
        />
        <ProjectCard
          title="Mobile App v2"
          status="Planning"
          members={6}
        />
        <ProjectCard
          title="API Migration"
          status="Complete"
          members={3}
        />
      </div>
 
      <RoleGate
        allowedRoles={["org:admin"]}
        fallback={
          <p className="text-sm text-gray-400">
            Only admins can manage project settings.
          </p>
        }
      >
        <div className="rounded-lg border border-red-200 bg-red-50 p-4">
          <h3 className="font-semibold text-red-800">Admin Actions</h3>
          <p className="mt-1 text-sm text-red-600">
            Archive, delete, or transfer projects.
          </p>
        </div>
      </RoleGate>
    </div>
  );
}
 
function ProjectCard({
  title,
  status,
  members,
}: {
  title: string;
  status: string;
  members: number;
}) {
  return (
    <div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
      <div>
        <h3 className="font-semibold">{title}</h3>
        <p className="text-sm text-gray-500">{members} members</p>
      </div>
      <span className="rounded-full bg-gray-100 px-3 py-1 text-xs font-medium">
        {status}
      </span>
    </div>
  );
}

Step 10: Protect API Routes

Basic Protected API Route

// src/app/api/projects/route.ts
import { auth } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
 
export async function GET() {
  const { userId, orgId } = await auth();
 
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!orgId) {
    return NextResponse.json(
      { error: "No organization selected" },
      { status: 400 }
    );
  }
 
  // Fetch projects for this organization from your database
  const projects = [
    { id: "1", name: "Website Redesign", orgId },
    { id: "2", name: "Mobile App v2", orgId },
  ];
 
  return NextResponse.json({ projects });
}
 
export async function POST(request: Request) {
  const { userId, orgId } = await auth();
 
  if (!userId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  if (!orgId) {
    return NextResponse.json(
      { error: "No organization selected" },
      { status: 400 }
    );
  }
 
  // Check permission
  const session = await auth();
  const canCreate = await session.has({ permission: "org:projects:create" });
 
  if (!canCreate) {
    return NextResponse.json({ error: "Forbidden" }, { status: 403 });
  }
 
  const body = await request.json();
 
  // Create project in your database
  const project = {
    id: crypto.randomUUID(),
    name: body.name,
    orgId,
    createdBy: userId,
  };
 
  return NextResponse.json({ project }, { status: 201 });
}

API Route with Role Validation

// src/app/api/organizations/members/route.ts
import { auth, clerkClient } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
 
export async function GET() {
  const { userId, orgId } = await auth();
 
  if (!userId || !orgId) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
 
  const client = await clerkClient();
  const members =
    await client.organizations.getOrganizationMembershipList({
      organizationId: orgId,
    });
 
  const formattedMembers = members.data.map((member) => ({
    id: member.publicUserData?.userId,
    name: `${member.publicUserData?.firstName} ${member.publicUserData?.lastName}`,
    email: member.publicUserData?.identifier,
    role: member.role,
    joinedAt: member.createdAt,
  }));
 
  return NextResponse.json({ members: formattedMembers });
}

Step 11: Handle Webhooks

Clerk sends webhooks for user and organization events. This is essential for syncing data with your database.

Install Svix for Webhook Verification

npm install svix

Create the Webhook Endpoint

// src/app/api/webhooks/clerk/route.ts
import { Webhook } from "svix";
import { headers } from "next/headers";
import { WebhookEvent } from "@clerk/nextjs/server";
import { NextResponse } from "next/server";
 
export async function POST(request: Request) {
  const SIGNING_SECRET = process.env.CLERK_WEBHOOK_SECRET;
 
  if (!SIGNING_SECRET) {
    throw new Error("Missing CLERK_WEBHOOK_SECRET environment variable");
  }
 
  const wh = new Webhook(SIGNING_SECRET);
  const headerPayload = await headers();
 
  const svixId = headerPayload.get("svix-id");
  const svixTimestamp = headerPayload.get("svix-timestamp");
  const svixSignature = headerPayload.get("svix-signature");
 
  if (!svixId || !svixTimestamp || !svixSignature) {
    return NextResponse.json(
      { error: "Missing svix headers" },
      { status: 400 }
    );
  }
 
  const payload = await request.json();
  const body = JSON.stringify(payload);
 
  let event: WebhookEvent;
 
  try {
    event = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    }) as WebhookEvent;
  } catch (err) {
    console.error("Webhook verification failed:", err);
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }
 
  switch (event.type) {
    case "user.created": {
      const { id, email_addresses, first_name, last_name } = event.data;
      console.log("New user created:", id);
      // Insert user into your database
      // await db.users.create({ clerkId: id, email: email_addresses[0]?.email_address, ... })
      break;
    }
 
    case "user.updated": {
      const { id } = event.data;
      console.log("User updated:", id);
      // Update user in your database
      break;
    }
 
    case "user.deleted": {
      const { id } = event.data;
      console.log("User deleted:", id);
      // Soft delete or remove user from your database
      break;
    }
 
    case "organization.created": {
      const { id, name, slug } = event.data;
      console.log("Organization created:", name);
      // Create organization in your database
      break;
    }
 
    case "organizationMembership.created": {
      const { organization, public_user_data, role } = event.data;
      console.log("New member added to org:", organization.id);
      // Add membership record to your database
      break;
    }
 
    default:
      console.log("Unhandled webhook event:", event.type);
  }
 
  return NextResponse.json({ received: true });
}

Configure the Webhook in Clerk

  1. Go to Webhooks in your Clerk dashboard
  2. Click Add endpoint
  3. Enter your URL: https://your-domain.com/api/webhooks/clerk
  4. Select events: user.created, user.updated, user.deleted, organization.created, organizationMembership.created
  5. Copy the Signing Secret and add it to .env.local:
CLERK_WEBHOOK_SECRET=whsec_your_webhook_secret

For local development, use ngrok or a similar tunneling tool:

npx ngrok http 3000

Step 12: Customize Clerk Components

Clerk components can be fully customized to match your application's design. There are two approaches: the appearance prop and the full theme system.

Global Theme Configuration

// src/app/layout.tsx
import { ClerkProvider } from "@clerk/nextjs";
import { dark } from "@clerk/themes";
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <ClerkProvider
      appearance={{
        // Use a base theme
        // baseTheme: dark,
        variables: {
          colorPrimary: "#2563eb",
          colorBackground: "#ffffff",
          colorInputBackground: "#f9fafb",
          colorInputText: "#111827",
          borderRadius: "0.5rem",
          fontFamily: "Inter, sans-serif",
        },
        elements: {
          formButtonPrimary:
            "bg-blue-600 hover:bg-blue-700 text-sm font-medium",
          card: "shadow-md border border-gray-100",
          headerTitle: "text-xl font-bold",
          headerSubtitle: "text-gray-500",
          socialButtonsBlockButton:
            "border border-gray-200 hover:bg-gray-50",
          formFieldInput:
            "border border-gray-300 focus:border-blue-500 focus:ring-blue-500",
          footerActionLink: "text-blue-600 hover:text-blue-700",
        },
      }}
    >
      <html lang="en">
        <body>{children}</body>
      </html>
    </ClerkProvider>
  );
}

Custom Sign-In with Branding

// src/app/sign-in/[[...sign-in]]/page.tsx
import { SignIn } from "@clerk/nextjs";
 
export default function SignInPage() {
  return (
    <div className="flex min-h-screen">
      {/* Left panel - Branding */}
      <div className="hidden w-1/2 bg-blue-600 lg:flex lg:flex-col lg:justify-center lg:px-12">
        <h1 className="text-4xl font-bold text-white">
          Welcome to SaaS Dashboard
        </h1>
        <p className="mt-4 text-lg text-blue-100">
          Manage your projects, collaborate with your team, and track
          progress — all in one place.
        </p>
        <div className="mt-8 space-y-4">
          <Feature text="Unlimited projects and team members" />
          <Feature text="Role-based access control" />
          <Feature text="Real-time collaboration" />
        </div>
      </div>
 
      {/* Right panel - Sign In */}
      <div className="flex w-full items-center justify-center lg:w-1/2">
        <SignIn
          appearance={{
            elements: {
              rootBox: "w-full max-w-md px-8",
              card: "shadow-none border-none",
            },
          }}
        />
      </div>
    </div>
  );
}
 
function Feature({ text }: { text: string }) {
  return (
    <div className="flex items-center gap-3">
      <svg
        className="h-5 w-5 text-blue-200"
        fill="currentColor"
        viewBox="0 0 20 20"
      >
        <path
          fillRule="evenodd"
          d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
          clipRule="evenodd"
        />
      </svg>
      <span className="text-blue-100">{text}</span>
    </div>
  );
}

Step 13: Organization Management Page

Build a dedicated page for managing organization members:

// src/app/dashboard/members/page.tsx
import { auth, clerkClient } from "@clerk/nextjs/server";
import { redirect } from "next/navigation";
import { RoleGate } from "@/components/role-gate";
import { InviteMemberForm } from "./invite-form";
 
export default async function MembersPage() {
  const { orgId, userId } = await auth();
 
  if (!orgId) {
    redirect("/dashboard");
  }
 
  const client = await clerkClient();
  const memberships =
    await client.organizations.getOrganizationMembershipList({
      organizationId: orgId,
    });
 
  return (
    <div className="space-y-8">
      <div className="flex items-center justify-between">
        <div>
          <h1 className="text-2xl font-bold">Team Members</h1>
          <p className="text-gray-500">
            Manage who has access to this organization.
          </p>
        </div>
        <RoleGate allowedRoles={["org:admin"]}>
          <InviteMemberForm orgId={orgId} />
        </RoleGate>
      </div>
 
      <div className="overflow-hidden rounded-lg border bg-white shadow-sm">
        <table className="min-w-full divide-y divide-gray-200">
          <thead className="bg-gray-50">
            <tr>
              <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                Member
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                Role
              </th>
              <th className="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
                Joined
              </th>
              <th className="px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500">
                Actions
              </th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-200">
            {memberships.data.map((member) => (
              <tr key={member.id}>
                <td className="whitespace-nowrap px-6 py-4">
                  <div className="flex items-center gap-3">
                    <img
                      src={member.publicUserData?.imageUrl}
                      alt=""
                      className="h-8 w-8 rounded-full"
                    />
                    <div>
                      <p className="font-medium">
                        {member.publicUserData?.firstName}{" "}
                        {member.publicUserData?.lastName}
                      </p>
                      <p className="text-sm text-gray-500">
                        {member.publicUserData?.identifier}
                      </p>
                    </div>
                  </div>
                </td>
                <td className="whitespace-nowrap px-6 py-4">
                  <span className="inline-flex rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800">
                    {member.role.replace("org:", "")}
                  </span>
                </td>
                <td className="whitespace-nowrap px-6 py-4 text-sm text-gray-500">
                  {new Date(member.createdAt).toLocaleDateString()}
                </td>
                <td className="whitespace-nowrap px-6 py-4 text-right">
                  <RoleGate allowedRoles={["org:admin"]}>
                    {member.publicUserData?.userId !== userId && (
                      <button className="text-sm text-red-600 hover:text-red-800">
                        Remove
                      </button>
                    )}
                  </RoleGate>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

Invite Member Form (Client Component)

// src/app/dashboard/members/invite-form.tsx
"use client";
 
import { useOrganization } from "@clerk/nextjs";
import { useState } from "react";
 
export function InviteMemberForm({ orgId }: { orgId: string }) {
  const { organization } = useOrganization();
  const [email, setEmail] = useState("");
  const [role, setRole] = useState("org:member");
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState("");
 
  async function handleInvite(e: React.FormEvent) {
    e.preventDefault();
    setIsLoading(true);
    setMessage("");
 
    try {
      await organization?.inviteMember({
        emailAddress: email,
        role: role as "org:admin" | "org:member",
      });
      setMessage("Invitation sent successfully!");
      setEmail("");
    } catch (error) {
      setMessage("Failed to send invitation. Please try again.");
    } finally {
      setIsLoading(false);
    }
  }
 
  return (
    <form onSubmit={handleInvite} className="flex items-end gap-3">
      <div>
        <label className="block text-sm font-medium text-gray-700">
          Email
        </label>
        <input
          type="email"
          value={email}
          onChange={(e) => setEmail(e.target.value)}
          placeholder="colleague@company.com"
          className="mt-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
          required
        />
      </div>
      <div>
        <label className="block text-sm font-medium text-gray-700">
          Role
        </label>
        <select
          value={role}
          onChange={(e) => setRole(e.target.value)}
          className="mt-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
        >
          <option value="org:member">Member</option>
          <option value="org:admin">Admin</option>
        </select>
      </div>
      <button
        type="submit"
        disabled={isLoading}
        className="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
      >
        {isLoading ? "Sending..." : "Invite"}
      </button>
      {message && (
        <p className="text-sm text-green-600">{message}</p>
      )}
    </form>
  );
}

Step 14: Add Multi-Factor Authentication

Clerk supports MFA out of the box. Enable it in your dashboard:

  1. Go to User and Authentication then Multi-factor in the Clerk dashboard
  2. Enable Authenticator application (TOTP)
  3. Optionally enable SMS verification

Users can then enable MFA from the UserProfile component:

// src/app/dashboard/settings/page.tsx
import { UserProfile } from "@clerk/nextjs";
 
export default function SettingsPage() {
  return (
    <div className="space-y-6">
      <div>
        <h1 className="text-2xl font-bold">Account Settings</h1>
        <p className="text-gray-500">
          Manage your profile, security, and preferences.
        </p>
      </div>
 
      <UserProfile
        appearance={{
          elements: {
            rootBox: "w-full",
            card: "shadow-sm border border-gray-200 w-full",
            navbar: "border-r border-gray-200",
          },
        }}
      />
    </div>
  );
}

Testing Your Implementation

1. Start the Development Server

npm run dev

2. Test the Authentication Flow

  1. Navigate to http://localhost:3000 — you should see the public home page
  2. Click sign up and create a new account
  3. Verify your email (check the Clerk test mode for instant verification)
  4. After sign-in, you should be redirected to /dashboard

3. Test Organization Features

  1. Click the OrganizationSwitcher in the navbar
  2. Create a new organization called "Acme Corp"
  3. Invite a member using a different email
  4. Switch between personal account and organization

4. Test RBAC

  1. As an admin, verify you can see the "New Project" button and admin actions
  2. Sign in with a member account and verify limited access
  3. Try accessing the API route /api/projects directly — it should require authentication

5. Test Webhook (Local Development)

# Terminal 1: Start your app
npm run dev
 
# Terminal 2: Start ngrok tunnel
npx ngrok http 3000

Copy the ngrok URL and update the webhook endpoint in your Clerk dashboard.

Troubleshooting

"auth() returned null userId"

Make sure your middleware is configured correctly and the route is not marked as public. Also verify that your CLERK_SECRET_KEY is set correctly.

"Organization features not showing"

Enable organizations in your Clerk dashboard under Organizations. This feature is not enabled by default.

"Webhook signature verification failed"

Double-check the CLERK_WEBHOOK_SECRET in your .env.local file. When using ngrok, make sure you are pointing the Clerk webhook to your ngrok URL, not localhost.

"CORS errors on API routes"

Clerk middleware handles CORS automatically for authenticated routes. If you are calling from an external origin, configure CORS headers explicitly in your API route.

"ClerkProvider not found"

Ensure ClerkProvider wraps your entire application in the root layout (app/layout.tsx), not in a nested layout.

Next Steps

Now that you have a fully functional authentication system, consider:

  • Database Integration: Connect Clerk user IDs to your database using Drizzle ORM or Prisma
  • Billing: Add Stripe for subscription management per organization
  • Audit Logs: Track all authentication and organization events
  • Custom Claims: Add custom metadata to user sessions for application-specific data
  • SSO/SAML: Enable enterprise SSO for larger customers

Conclusion

In this tutorial, you built a complete authentication system with Clerk and Next.js 15 that includes:

  • User registration and sign-in with multiple providers
  • Organization management for multi-tenancy
  • Role-based access control with custom permissions
  • Protected API routes with permission checks
  • Webhook handling for database synchronization
  • Customized authentication UI components
  • Multi-factor authentication support

Clerk eliminates the need to build and maintain complex authentication infrastructure, letting you focus on your application's core features. Its deep integration with Next.js App Router and Server Components makes it a natural choice for modern React applications.

The combination of organizations and RBAC makes this setup ideal for SaaS applications where team collaboration and access control are essential. With Clerk's managed infrastructure, you get enterprise-grade security without the operational overhead of self-hosted solutions.


Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Build a Full-Stack App with Appwrite Cloud and Next.js 15

Learn how to build a complete full-stack application using Appwrite Cloud as your backend-as-a-service and Next.js 15 App Router. Covers authentication, databases, file storage, and real-time features.

30 min read·