Clerk Integration & Multitenant Strategy
Overview
ropa2.0 uses Clerk as the identity provider and organization manager. MongoDB stores all domain-specific ROPA/contract data. The two systems are linked via a stored clerkOrganizationId field and kept in sync through webhooks and server actions.
Each tenant is identified by a subdomain (<shortName>.rat.gd in production, <shortName>.localhost in development). The shortName is the stable, immutable tenant identifier used throughout MongoDB and as the Clerk organization slug.
Architecture
Browser (subdomain)
│
├─ TenantSync.jsx sets Clerk active org = current subdomain
│
├─ Server Actions auth() / clerkClient() for Clerk API calls
│
└─ Webhook (POST /api/webhooks/clerk)
│
├─ organization.updated → sync logo, block slug changes
└─ organization.deleted → cascade-delete MongoDB data
Key invariant
organization.shortNamein MongoDB always equalsorganization.slugin Clerk.
Slug changes initiated in Clerk are immediately rolled back by the webhook (see Slug Change Prevention).
MongoDB ↔ Clerk Linking
Schema
clerkOrganizationId is stored at the top level of the Organization document:
// src/models/organization.js
clerkOrganizationId: {
type: String,
trim: true,
sparse: true,
index: true, // indexed — used in every webhook lookup
}
Lookup direction
| From | To | Function |
|---|---|---|
| Clerk webhook event | MongoDB org | getOrganizationByClerkOrganizationId(clerkOrganizationId) |
| MongoDB short name | Clerk org | clerkClient().organizations.getOrganization({ slug: shortName }) |
Saving the link
// src/lib/mongoose/organizationClerkOrganizationIdSave.js
organizationClerkOrganizationIdSave({ shortName, clerkOrganizationId })
// → calls saveClerkOrganizationId() in the service layer
Tenant Identification
Server side
getHostnameServer() (src/lib/url/getHostnameServer.js) reads the host request header and extracts the subdomain:
| Host | Resolved shortName |
|---|---|
acme.rat.gd | acme |
acme.localhost | acme |
localhost (plain) | demo |
Fallback is always demo.
Client side
getSubdomainClient() (src/lib/url/getSubdomainClient.js) reads window.location.hostname with the same logic. Returns null on the root domain.
TenantSync
TenantSync.jsx (src/lib/clerk/TenantSync.jsx) is a client component mounted in the app layout. On mount it:
- Calls
getSubdomainClient()to get the current subdomain. - Lists the user's Clerk organization memberships via
useOrganizationList. - Finds the membership whose
organization.slugmatches the subdomain. - Calls
setActive({ organization: matchingOrg.id })so Clerk's session context reflects the correct tenant.
This ensures auth().orgId / useOrganization() always return the subdomain tenant, not whatever Clerk last remembered.
Organization Creation
From template (organizationFromTemplateCreate)
Used by the admin recreate-demo route and provisioning flows.
organizationFromTemplateCreate(shortName, userId)
│
├─ Resolve userId (from arg or auth())
├─ Load template metadata from filesystem
├─ Create ROPAs for each configured locale
├─ Create MongoDB organization document
│
└─ syncClerkOrganization(shortName, userId, client)
├─ Try getOrganization({ slug: shortName })
│ ├─ Found → ensure user has admin membership
│ └─ Not found → createOrganization({ name, slug, createdBy: userId })
└─ saveClerkOrganizationId(shortName, clerkOrg.id)
KNOWN_CLERK_ORG_IDS hard-codes the Clerk ID for the demo tenant so it survives database resets without re-creating the Clerk org.
User roles and membership are managed entirely by Clerk — the MongoDB organization document does not store a separate user or admin list.
From new (organizationCreate)
Accepts an optional clerkOrganizationId. If provided, it is stored immediately. If not, it must be linked later via organizationClerkOrganizationIdSave.
Webhook Handler
Route: POST /api/webhooks/clerk
File: src/app/api/webhooks/clerk/route.js
Verifies the Svix signature using verifyWebhook from @clerk/nextjs/webhooks. Returns 400 on invalid signature. All other responses are 200 (including error cases) to prevent Clerk from retrying.
| Event | Handler |
|---|---|
organization.updated | organizationUpdate() — logo sync, slug rollback, and (for test orgs) name + logo lock |
organization.deleted | organizationDelete() — cascade-delete MongoDB data |
| (anything else) | 200 OK, no-op |
Slug Change Prevention
File: src/lib/webhooks/organizationUpdate.js
Clerk admins can edit the organization slug in the Clerk dashboard. ropa2.0 prevents this because shortName is used as the stable tenant identifier in MongoDB, URLs, and subdomain routing.
Flow:
organization.updatedwebhook fires withslugfield.- Handler looks up
shortNamein MongoDB. - If
newSlug !== shortName, Clerk is called immediately to roll it back:client.organizations.updateOrganization(clerkOrganizationId, {slug: oldShortName,publicMetadata: { slugChangeWarning: true },}); - The
slugChangeWarning: trueflag in ClerkpublicMetadatacauses the UI to show a dismissible warning banner. - When the user dismisses it,
organizationSlugChangeWarningClear()sets the flag back tofalse.
File: src/lib/mongoose/organizationSlugChangeWarningClear.js — reads auth() to get the Clerk org ID, then patches publicMetadata.
Logo Sync
Logo sync is bidirectional. The own-organization entity is always organizationId === 0 inside organization.organizations[]. Only this entity maps to the Clerk org logo.
Logo is stored in MongoDB as a Buffer (PNG, max 256 KB) on the organizationLogo field of the entity subdocument.
Clerk → MongoDB (webhook)
Triggered by organization.updated when image_url is present or cleared.
syncLogo({ shortName, entity, imageUrl })
├─ imageUrl falsy → updateOrganizationEntity(..., { organizationLogo: null })
└─ imageUrl present
├─ fetch(imageUrl)
├─ sharp().resize(256, 256, { fit: "cover" }).png().withMetadata(false)
└─ updateOrganizationEntity(..., { organizationLogo: buffer })
Errors at any step (fetch, sharp, DB) are logged as warnings and do not fail the webhook response.
MongoDB → Clerk (server action)
File: src/lib/clerk/updateOrganizationLogo.js
Called by organizationLogoUpdate and organizationLogoClear after a successful DB write, only when organizationId === 0.
updateOrganizationLogo(clerkOrganizationId, logoBuffer)
├─ logoBuffer null → client.organizations.deleteOrganizationLogo(...)
└─ logoBuffer set → new File([logoBuffer], "logo.png", { type: "image/png" })
client.organizations.updateOrganizationLogo(..., { file, uploaderUserId })
uploaderUserId comes from auth(). Errors are caught and logged as warnings — they never surface to the user.
Loop prevention
A MongoDB → Clerk push triggers an organization.updated webhook, which calls syncLogo again. This is one extra DB write (same bytes written back), not an infinite loop: syncLogo writes to MongoDB only, never back to Clerk.
Organization Deletion
File: src/lib/webhooks/organizationDelete.js
When a Clerk organization is deleted, the webhook cascade-deletes all MongoDB data for that tenant in parallel:
- Organization document (
deleteOrganizationByShortName) - All ROPA documents (
deleteRopasByShortName) - All org templates (
deleteOrgTemplatesByShortNameRW)
Returns 500 only if all three deletions fail; partial failures return 200 after logging.
Environment Variables
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY | Clerk frontend key |
CLERK_SECRET_KEY | Clerk backend/API key |
CLERK_WEBHOOK_SIGNING_SECRET | Svix webhook signature verification |
NEXT_PUBLIC_SITE_DOMAIN | Production domain for subdomain routing (e.g. rat.gd) |
File Map
| File | Role |
|---|---|
src/lib/clerk/TenantSync.jsx | Client component — syncs Clerk active org to subdomain |
src/lib/clerk/updateOrganizationLogo.js | Server action — push logo Buffer to Clerk |
src/lib/clerk/index.js | Barrel (server-safe exports only — no client components) |
src/app/api/webhooks/clerk/route.js | Webhook entry point, signature verification, event routing |
src/lib/webhooks/organizationUpdate.js | Handles organization.updated: logo sync + slug rollback |
src/lib/webhooks/organizationDelete.js | Handles organization.deleted: cascade delete MongoDB data |
src/lib/mongoose/organizationFromTemplateCreate.js | Create org from template + create/link Clerk org |
src/lib/mongoose/organizationCreate.js | Create blank org, optionally accept clerkOrganizationId |
src/lib/mongoose/organizationClerkOrganizationIdSave.js | Link existing MongoDB org to Clerk org |
src/lib/mongoose/organizationSlugChangeWarningClear.js | Dismiss slug change warning flag in Clerk metadata |
src/lib/mongoose/organizationLogoUpdate.js | Save logo to MongoDB + trigger Clerk sync |
src/lib/mongoose/organizationLogoClear.js | Clear logo in MongoDB + trigger Clerk sync |
src/lib/url/getHostnameServer.js | Extract shortName from request headers (server) |
src/lib/url/getSubdomainClient.js | Extract shortName from window.location (client) |
src/services/organizationService.js | saveClerkOrganizationId, getOrganizationByClerkOrganizationId |