Cloudinary is the centrepiece of this app. It handles upload, AI background removal, content moderation, dynamic card composition, and optimised delivery — without any server-side image processing in our Node process.
User picks a photo
│
▼
POST /api/sign-upload (server signs params)
│
▼
Browser POSTs file directly to Cloudinary
│
├─ Cloudinary stores the asset at a deterministic public_id
│
└─ Async aws_rek moderation runs
│
▼
POST /api/cloudinary/webhook (moderation verdict)
│
├─ approved → card stays, client notified via Supabase Realtime
└─ rejected → card + asset deleted, user shown reason
Why signed, not unsigned or an upload preset?
Upload presets are global config and can't vary per-user or include dynamic values like the user's ID. Unsigned uploads give the client full control over the destination path and parameters. Signed uploads let the server set the exact public_id, moderation parameters, and notification_url for this specific upload, then prove it with a signature the client can't forge.
Flow (lib/cloudinary/sign.ts + app/api/sign-upload/route.ts):
- Component calls
POST /api/sign-uploadwith{ kind: 'avatar' | 'card' }. - The route verifies the Supabase session.
getSignedUploadParams()builds the upload params and signs them with HMAC-SHA1 (api_sign_request).- The browser POSTs the file + signed params directly to
https://api.cloudinary.com/v1_1/{cloudName}/image/upload. - Cloudinary validates the signature, stores the asset, and starts moderation.
The signature covers timestamp, public_id, overwrite, moderation, and notification_url. Tampering with any of these invalidates it.
Cloudinary has two folder modes. This app works with both.
| Mode | How it works |
|---|---|
| Fixed folders | The public_id IS the full path. kickoff/users/abc/avatar → folder kickoff/users/abc, asset name avatar. Slashes in the URL are the folder hierarchy. |
| Dynamic folders | public_id and asset_folder are decoupled. public_id can still contain slashes and those slashes appear in delivery URLs, but folder organisation in the Media Library console is driven by asset_folder, not the public_id path. Default for accounts created after mid-2023. |
Why both work: delivery URLs are constructed from public_id regardless of folder mode — cloudinary.url("templates/arg", {...}) produces the same URL in either mode, as long as the asset was uploaded with public_id: "templates/arg". All uploads in this app use signed uploads with explicit public_id, so the path is always set correctly.
What the code does for Dynamic folder accounts: lib/cloudinary/sign.ts includes asset_folder in both the upload parameters and the HMAC signature. Cloudinary uses asset_folder to organise assets in the Media Library console in dynamic-folder mode. In fixed-folder mode, asset_folder is ignored — the folder is inferred from the public_id path. Either way the delivery URL and all SDK calls (cloudinary.api.resource, uploader.destroy, layer overlays) work identically.
Template uploads must always set an explicit public_id. Use the provided script:
pnpm upload-templates --dir ./path/to/template-imagesThe script uploads each nation template with public_id: "templates/{nation}" and asset_folder: "templates", which works correctly in both modes. Never upload templates using the Cloudinary dashboard's auto-name feature — the resulting public_id will be a UUID and cloudinary.url("templates/arg") will produce a broken URL.
Check your account mode: Cloudinary dashboard → Settings → Upload → Folder behavior.
All user assets live under a user-scoped prefix (lib/cloudinary/publicIds.ts):
kickoff/users/{userId}/avatar ← avatar (overwrite on re-upload)
kickoff/users/{userId}/uploads/{uuid} ← per-card photo (unique per upload)
templates/{nation} ← card backgrounds (pre-uploaded, no prefix)
isOwnedBy(publicId, userId) is a prefix check used in every server action before any Cloudinary operation. This is the app-layer equivalent of RLS — no user can delete or reference another user's assets.
Used in two places (lib/cloudinary/urls.ts):
getRemovedBgUrl(publicId)— transparent PNG with background cut out, used as a source for card composition.getAvatarUrl(publicId)— background removed, then face-centred, circle-cropped to 160×160.
// Avatar pipeline
{ effect: 'background_removal' },
{ crop: 'fill', gravity: 'face', width: 160, height: 160, radius: 'max' }g_face centres the crop on the detected face. radius: 'max' clips to a circle in the Cloudinary layer itself, not in CSS.
Cloudinary caches transformed assets after the first request — background removal only runs once per asset, subsequent requests hit the CDN.
The showcase feature — lib/cloudinary/buildCardUrl.ts.
The card is a single Cloudinary URL. No server-side image generation; Cloudinary executes the transformation chain on first request and caches it.
Transformation chain:
Base: templates/{nation} ← 896×1200 card template PNG
│
├─ overlay: {user photo public_id} ← open the user's photo as a layer
├─ effect: background_removal ← AI-remove the background
├─ crop: fill, gravity: face, ← face-anchored crop to the card's photo window
│ width: tmpl.photo.w, height: tmpl.photo.h
├─ flags: layer_apply, gravity, x, y ← composite at template coordinates
│
├─ (× 6) overlay: {stat text} ← stat number as text layer (Arial Bold 44px)
└─ (× 6) flags: layer_apply, x, y ← position at template slot
│
└─ fetch_format: auto, quality: auto ← f_auto / q_auto delivery
Layer IDs use : instead of / (Cloudinary escaping). The public_id kickoff/users/abc/uploads/xyz becomes layer id kickoff:users:abc:uploads:xyz.
Stat coordinates come from lib/cloudinary/templates.ts. All six nations currently share a single SHARED layout (card templates are the same design). To add per-nation layouts, add a key to TEMPLATES.
On level-up, awardXp regenerates the card URL with incremented stats. The new URL is stored in cards.card_url. Old URLs continue to work (Cloudinary doesn't delete the cache), but won't reflect the new stats.
Add-on required: AWS Rekognition (paid).
Every upload includes the moderation parameter in the signed params:
aws_rek:explicit_nudity:0.4:suggestive:0.4:violence:0.4:rude_gestures:0.4:visually_disturbing:0.4:drugs:0.4:hate_symbols:0.4
0.4 is the rejection threshold per category (0.0–1.0). Cloudinary runs the check asynchronously and calls CLOUDINARY_WEBHOOK_URL with the result.
Webhook (app/api/cloudinary/webhook/route.ts):
- Verify the
x-cld-signatureheader to confirm the request is from Cloudinary. - Parse
moderation_status(approvedorrejected) andpublic_id. - Update
cards.moderation_statusin Supabase. - On rejection: delete the card rows and call
cloudinary.uploader.destroy(publicId).
The cards table is in the Supabase Realtime publication, so the client's subscription fires immediately on the status change — no polling needed.
All card and avatar URLs include:
f_auto(fetch_format: 'auto') — Cloudinary serves WebP or AVIF to browsers that support it, falling back to the original format.q_auto— Cloudinary picks the optimal quality level automatically.
These are set in buildCardUrl.ts and urls.ts. Combined they typically cut bandwidth by 30–60% vs naïve JPEG delivery.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME |
Cloud name — safe for client, used in upload URLs |
NEXT_PUBLIC_CLOUDINARY_API_KEY |
API key — safe for client, included in signed upload requests |
CLOUDINARY_API_SECRET |
Used to generate signatures — server only, never exposed |
CLOUDINARY_WEBHOOK_URL |
Public HTTPS URL Cloudinary calls after moderation |