Skip to main content

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.shortName in MongoDB always equals organization.slug in 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

FromToFunction
Clerk webhook eventMongoDB orggetOrganizationByClerkOrganizationId(clerkOrganizationId)
MongoDB short nameClerk orgclerkClient().organizations.getOrganization({ slug: shortName })
// 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:

HostResolved shortName
acme.rat.gdacme
acme.localhostacme
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:

  1. Calls getSubdomainClient() to get the current subdomain.
  2. Lists the user's Clerk organization memberships via useOrganizationList.
  3. Finds the membership whose organization.slug matches the subdomain.
  4. 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.

EventHandler
organization.updatedorganizationUpdate() — logo sync, slug rollback, and (for test orgs) name + logo lock
organization.deletedorganizationDelete() — 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:

  1. organization.updated webhook fires with slug field.
  2. Handler looks up shortName in MongoDB.
  3. If newSlug !== shortName, Clerk is called immediately to roll it back:
    client.organizations.updateOrganization(clerkOrganizationId, {
    slug: oldShortName,
    publicMetadata: { slugChangeWarning: true },
    });
  4. The slugChangeWarning: true flag in Clerk publicMetadata causes the UI to show a dismissible warning banner.
  5. When the user dismisses it, organizationSlugChangeWarningClear() sets the flag back to false.

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 setnew 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

VariablePurpose
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEYClerk frontend key
CLERK_SECRET_KEYClerk backend/API key
CLERK_WEBHOOK_SIGNING_SECRETSvix webhook signature verification
NEXT_PUBLIC_SITE_DOMAINProduction domain for subdomain routing (e.g. rat.gd)

File Map

FileRole
src/lib/clerk/TenantSync.jsxClient component — syncs Clerk active org to subdomain
src/lib/clerk/updateOrganizationLogo.jsServer action — push logo Buffer to Clerk
src/lib/clerk/index.jsBarrel (server-safe exports only — no client components)
src/app/api/webhooks/clerk/route.jsWebhook entry point, signature verification, event routing
src/lib/webhooks/organizationUpdate.jsHandles organization.updated: logo sync + slug rollback
src/lib/webhooks/organizationDelete.jsHandles organization.deleted: cascade delete MongoDB data
src/lib/mongoose/organizationFromTemplateCreate.jsCreate org from template + create/link Clerk org
src/lib/mongoose/organizationCreate.jsCreate blank org, optionally accept clerkOrganizationId
src/lib/mongoose/organizationClerkOrganizationIdSave.jsLink existing MongoDB org to Clerk org
src/lib/mongoose/organizationSlugChangeWarningClear.jsDismiss slug change warning flag in Clerk metadata
src/lib/mongoose/organizationLogoUpdate.jsSave logo to MongoDB + trigger Clerk sync
src/lib/mongoose/organizationLogoClear.jsClear logo in MongoDB + trigger Clerk sync
src/lib/url/getHostnameServer.jsExtract shortName from request headers (server)
src/lib/url/getSubdomainClient.jsExtract shortName from window.location (client)
src/services/organizationService.jssaveClerkOrganizationId, getOrganizationByClerkOrganizationId