DS DevShelfHub Projects · AI tools
Articles / Build a B2B SaaS App with FastAPI, React, and Clerk: Orgs, Roles, Permissions, and Billing

AI Engineering

FastAPI + React B2B SaaS with Clerk: Multi-Tenant Auth in a Weekend

By DevShelfHub

An end-to-end architecture for a multi-tenant B2B SaaS — FastAPI backend, React frontend, Clerk for auth, organizations, permissions, and billing. Covers JWT verification, the can_view/can_create permission pattern, org-scoped task models with IDOR defenses, protected React routes, subscription tiers, webhook-driven seat-limit updates, and the production gotchas that bite first-timers.

FastAPI + React B2B SaaS with Clerk: Multi-Tenant Auth in a Weekend

Introduction

B2B SaaS apps share the same hard pieces: organizations with multiple members, role-based permissions, billing tied to organization tier, secure auth, and data scoped per tenant. Build all of that from scratch and you’ll spend two months on plumbing before the actual product takes shape.

This walkthrough takes a different approach. FastAPI for the backend, React for the frontend, Clerk for the auth, organizations, members, permissions, and billing — so you can ship a working tenant-aware app in a weekend. Aimed at developers who already know FastAPI and React and want to see how the pieces snap together.

📚 Table of contents

  • What we’re building (TeamFlow)
  • Why Clerk earns its place in the stack
  • Project layout
  • Setting up Clerk: app, sign-in methods, organizations
  • FastAPI auth dependency — verifying Clerk JWTs
  • Modeling tasks scoped to an organization
  • Permissions: the can_view / can_create pattern
  • Task CRUD endpoints with permission checks
  • React: protected routes and organization context
  • Billing tiers and the Clerk subscription model
  • Webhooks — expanding seat limits on upgrade
  • Common mistakes
  • FAQs

What we’re building (TeamFlow)

A multi-tenant task management SaaS. Each user belongs to one or more organizations. Each organization has members with roles (admin, member). Tasks are scoped to an organization and gated by per-feature permissions (view/create/edit/delete). Plans gate seat counts and feature access.

Concrete features:

  • Sign up, sign in, sign out, password reset
  • Create an organization, invite members, manage roles
  • CRUD tasks scoped to the active organization
  • Pricing page with free/pro tiers
  • Upgrade to Pro to expand the seat limit
  • Webhook-driven seat-limit updates after purchase

Why Clerk earns its place in the stack

You could write all of this in plain FastAPI. Most teams shouldn’t. The Clerk-vs-DIY trade-off:

✅ What Clerk gives you

  • Sign-in flows for email, Google, Facebook, GitHub — toggle on/off from dashboard.
  • Organizations: create, switch, member management, invitations, all UI components included.
  • Role-based permissions with a JWT claim model.
  • Subscriptions and pricing UIs via Stripe under the hood.
  • Pre-built React components: <SignIn/>, <CreateOrganization/>, <UserButton/>.
  • Generous free tier — works fine for small SaaS apps in production.

❌ Trade-offs

  • You depend on a third party for a load-bearing service.
  • Pricing changes once you scale past the free tier.
  • Some customization gets fiddly — the styled components have a Clerk look.

Project layout

tree
teamflow/
  backend/
    app/
      main.py
      auth.py           # Clerk JWT verification + user object
      db.py             # SQLAlchemy session
      models.py         # Task, Organization (mirror)
      schemas.py        # Pydantic schemas
      permissions.py    # has_permission helpers
      routers/
        tasks.py
        webhooks.py
  frontend/
    src/
      pages/{Home,SignIn,SignUp,Dashboard,Pricing}.jsx
      components/{TaskList,TaskForm,Layout}.jsx
      App.jsx
      main.jsx

Setting up Clerk: app, sign-in methods, organizations

Create a new Clerk application from the dashboard. The setup walks you through:

  1. Pick sign-in methods. Start with email; you can enable Google, GitHub, etc. later with a toggle.
  2. Enable Organizations in the Clerk dashboard. This adds the organization model to every JWT Clerk issues.
  3. Define roles: org:admin and org:member.
  4. Define permissions per feature: org:tasks:view, org:tasks:create, org:tasks:edit, org:tasks:delete.
  5. Map permissions to roles — admins get all four, members get view + create.
  6. Copy the publishable key (frontend) and secret key (backend) into your env files.

FastAPI auth dependency — verifying Clerk JWTs

The browser holds a Clerk-issued JWT and sends it as a Bearer token. The backend verifies it on every request and pulls the user, organization, and permission claims:

app/auth.py
from fastapi import Depends, HTTPException, Header
from jose import jwt
from pydantic import BaseModel

class ClerkUser(BaseModel):
    user_id: str
    org_id: str | None
    role: str | None
    permissions: list[str]

    def has_permission(self, perm: str) -> bool:
        return perm in self.permissions

    @property
    def can_view(self):   return self.has_permission("org:tasks:view")
    @property
    def can_create(self): return self.has_permission("org:tasks:create")
    @property
    def can_edit(self):   return self.has_permission("org:tasks:edit")
    @property
    def can_delete(self): return self.has_permission("org:tasks:delete")

def current_user(authorization: str = Header(...)) -> ClerkUser:
    token = authorization.removeprefix("Bearer ").strip()
    claims = jwt.decode(token, JWKS, audience="...")  # verify with Clerk JWKS
    return ClerkUser(
        user_id=claims["sub"],
        org_id=claims.get("org_id"),
        role=claims.get("org_role"),
        permissions=claims.get("org_permissions", []),
    )

The can_view-style properties keep your handlers readable: if not user.can_create: raise HTTPException(403). Add require_create, require_edit, etc. dependencies for one-liner gating.

Modeling tasks scoped to an organization

Every task carries its owning org_id. The list/get queries always filter by the active org from the JWT — that’s how multi-tenancy stays correct even if a malicious request tries to fetch another org’s task IDs.

app/models.py
class Task(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    org_id: str = Field(index=True)
    created_by: str
    title: str
    description: str = ""
    status: str = "pending"   # pending | in_progress | done
    created_at: datetime = Field(default_factory=datetime.utcnow)

Task CRUD endpoints with permission checks

app/routers/tasks.py
router = APIRouter(prefix="/tasks", tags=["tasks"])

@router.get("/", response_model=list[TaskRead])
def list_tasks(user: ClerkUser = Depends(require_view), session=Depends(get_session)):
    return session.exec(select(Task).where(Task.org_id == user.org_id)).all()

@router.post("/", response_model=TaskRead, status_code=201)
def create_task(
    data: TaskCreate,
    user: ClerkUser = Depends(require_create),
    session=Depends(get_session),
):
    task = Task(**data.model_dump(), org_id=user.org_id, created_by=user.user_id)
    session.add(task); session.commit(); session.refresh(task)
    return task

@router.patch("/{task_id}")
def edit_task(task_id: int, data: TaskUpdate,
              user: ClerkUser = Depends(require_edit),
              session=Depends(get_session)):
    task = session.get(Task, task_id)
    if not task or task.org_id != user.org_id:
        raise HTTPException(404)
    for k, v in data.model_dump(exclude_unset=True).items():
        setattr(task, k, v)
    session.commit(); session.refresh(task)
    return task

@router.delete("/{task_id}", status_code=204)
def delete_task(task_id: int,
                user: ClerkUser = Depends(require_delete),
                session=Depends(get_session)):
    task = session.get(Task, task_id)
    if task and task.org_id == user.org_id:
        session.delete(task); session.commit()

The two-layer check — permission via dependency, org membership via task.org_id != user.org_id — is your IDOR (insecure direct object reference) defense. Always run it on every endpoint that touches tenant data.

React: protected routes and organization context

Clerk’s React SDK exposes <SignedIn>, <SignedOut>, and <RedirectToSignIn> components. Wrap protected routes:

App.jsx
import { Routes, Route } from "react-router-dom";
import { SignedIn, SignedOut, RedirectToSignIn } from "@clerk/clerk-react";

function Protected({ children }) {
  return (
    <>
      <SignedIn>{children}</SignedIn>
      <SignedOut><RedirectToSignIn /></SignedOut>
    </>
  );
}

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/sign-in/*" element={<SignIn />} />
      <Route path="/sign-up/*" element={<SignUp />} />
      <Route path="/dashboard" element={<Protected><Dashboard /></Protected>} />
      <Route path="/pricing" element={<Pricing />} />
    </Routes>
  );
}

Inside Dashboard, the useOrganization() hook tells you the active org; useUser() gives the user. If the user isn’t in an org yet, render Clerk’s <CreateOrganization /> component and the org gets created without a single API call you wrote.

Billing tiers and the Clerk subscription model

Pricing in Clerk is configured from the dashboard: define plans (Free, Pro, Enterprise), associate features and seat limits with each, and embed the Clerk pricing component in your React app. Clerk handles the Stripe checkout flow, displays the upgrade UI, and manages subscription state.

The Free plan in this app gets one project and 1 seat. Pro lifts the seat cap. Enterprise removes feature gates entirely. The frontend reads the active plan from Clerk and conditionally renders upsell prompts:

JSX
const { organization } = useOrganization();
const plan = organization?.publicMetadata?.plan ?? "free";

{plan === "free" && tasks.length >= 1 && (
  <UpgradePrompt feature="Unlimited projects" />
)}

Webhooks — expanding seat limits on upgrade

By default a free Clerk org has a fixed member cap. When a Pro upgrade succeeds, you need to bump that cap. Two pieces:

  1. Register a webhook in Clerk pointing at POST /webhooks/clerk on your FastAPI backend. Subscribe to subscription.updated events.
  2. On webhook receipt, verify the Svix signature (Clerk uses Svix for signed delivery), then call Clerk’s Backend API to update organization.max_allowed_memberships.
app/routers/webhooks.py
@router.post("/webhooks/clerk")
async def clerk_webhook(req: Request):
    payload = await req.body()
    headers = dict(req.headers)
    event = svix.verify(payload, headers, secret=CLERK_WEBHOOK_SECRET)
    if event["type"] == "subscription.updated":
        org_id = event["data"]["organization_id"]
        new_cap = 50 if event["data"]["plan"] == "pro" else 1
        clerk_sdk.update_organization(org_id, max_allowed_memberships=new_cap)
    return {"ok": True}

Always verify the signature. An unauthenticated webhook endpoint that mutates billing state is a classic vulnerability.

❌ Common mistakes

  • Filtering tasks by user_id instead of org_id. Multi-tenant data must scope by tenant first, user second.
  • Trusting permission claims without verifying the JWT signature. Anyone can claim anything in an unverified token.
  • Forgetting IDOR checks — task.org_id != user.org_id — on edit/delete endpoints.
  • Not verifying webhook signatures. Unauthenticated state-changing endpoints are a free hack.
  • Hardcoding role checks in the React UI. Always re-check on the backend — the frontend is suggestion, not security.
  • Letting Clerk own your user IDs in a way you can’t migrate away from. Store user_id from JWT claims in your own DB so you can swap auth providers later if needed.

💡 Pro tips

  • Define every permission as org:<feature>:<action>. It scales cleanly to a dozen features without renaming.
  • Build a require_permission(perm) factory so every endpoint dependency is one line.
  • Use Clerk’s publicMetadata for plan-derived feature flags. Server reads it from JWT claims; frontend reads it from the SDK.
  • For backups and migrations, store Clerk user/org IDs as foreign keys but also persist a mirror table you control.
  • Run your webhook handler with idempotency — Clerk retries on 5xx, so the same event can arrive twice.

Conclusion

The hardest parts of a B2B SaaS app aren’t the features — they’re the cross-cutting concerns: auth, organizations, permissions, billing, webhooks. Clerk plus FastAPI plus React lets you handle all of those in days instead of months, and the resulting code is small enough to read end-to-end in an afternoon.

Next step: add Postgres in place of SQLite, deploy the backend to Render or Fly.io, host the frontend on Vercel, and you have a real production stack for under $20/month at small scale.

Related reading: Django YouTube clone with ImageKit tutorialproduction web scraping architecture in PythonClaude AI review

Explore More on DevShelf

Build a B2B SaaS App with FastAPI, React, and Clerk: Orgs, Roles, Permissions, and Billing FAQ

Can I use Supabase / Auth0 / WorkOS instead of Clerk?

Yes—the architecture is identical, only the SDK changes. Supabase and Auth0 cover auth well; WorkOS is stronger for enterprise (SAML, SCIM). Clerk's edge is built-in organizations and pre-styled React components.

Why FastAPI instead of Django?

Async-friendly, smaller surface, and type-driven validation through Pydantic. Django is a reasonable alternative for admin interface and ORM ergonomics. For API-first SaaS, FastAPI usually wins.

Will this scale past 10,000 users?

Yes, with three changes: Postgres on a managed service (RDS, Neon, Supabase), a Redis layer for JWT caching, and rate-limiting at the API gateway. Clerk's service tier handles auth volume without tuning.

How do I handle user invites?

Clerk's built-in OrganizationProfile component includes a member management UI with invitations. Email invitations go through Clerk's mail or a custom template. You don't write the flow yourself.

Can I customize the Clerk UI?

Components accept appearance props for theming; you can swap in fully custom flows using Clerk's low-level APIs if you outgrow the styled defaults. Most teams start with defaults and customize when design demands.

Is this overkill for a side project?

If the side project might become a real product, no. The added Clerk complexity is small; the headache saved on auth, orgs, and billing is large. For a one-off personal tool with no multi-user needs, skip Clerk and use a simpler stack.