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
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:
- Pick sign-in methods. Start with email; you can enable Google, GitHub, etc. later with a toggle.
- Enable Organizations in the Clerk dashboard. This adds the organization model to every JWT Clerk issues.
- Define roles:
org:adminandorg:member. - Define permissions per feature:
org:tasks:view,org:tasks:create,org:tasks:edit,org:tasks:delete. - Map permissions to roles — admins get all four, members get view + create.
- 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:
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.
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
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:
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:
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:
- Register a webhook in Clerk pointing at
POST /webhooks/clerkon your FastAPI backend. Subscribe tosubscription.updatedevents. - 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.
@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_idinstead oforg_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_idfrom 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
publicMetadatafor 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 tutorial — production web scraping architecture in Python — Claude AI review
Explore More on DevShelf
-
Build a Django YouTube Clone with ImageKit
A Python-native companion build — video upload, transcoding, and streaming using Django and ImageKit instead of FastAPI.
-
Build an AI Email Assistant with Postmark and Anthropic
Add AI-powered email to your SaaS — Postmark webhooks, Drizzle ORM, and Claude generating replies at a real email address.