<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nick Bradley</title>
    <description>The latest articles on DEV Community by Nick Bradley (@nick_bradley_a4327ddd434b).</description>
    <link>https://dev.to/nick_bradley_a4327ddd434b</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4009695%2F57577d7a-d528-4765-984d-b5964081c6fe.png</url>
      <title>DEV Community: Nick Bradley</title>
      <link>https://dev.to/nick_bradley_a4327ddd434b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nick_bradley_a4327ddd434b"/>
    <language>en</language>
    <item>
      <title>My Agent Built a Paid Media Storefront Before I Signed Up for Anything</title>
      <dc:creator>Nick Bradley</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:53:25 +0000</pubDate>
      <link>https://dev.to/cloudinary/my-agent-built-a-paid-media-storefront-before-i-signed-up-for-anything-2k47</link>
      <guid>https://dev.to/cloudinary/my-agent-built-a-paid-media-storefront-before-i-signed-up-for-anything-2k47</guid>
      <description>&lt;p&gt;Here's a build log that would have been impossible a year ago.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;I told an agent to clone &lt;a href="https://github.com/cloudinary-devs/unshutter" rel="noopener noreferrer"&gt;unshutter&lt;/a&gt; and get it running.&lt;/li&gt;
&lt;li&gt;It provisioned a Cloudinary Claimable Cloud, a Neon Postgres database and a Stripe sandbox, &lt;strong&gt;without me creating a single account.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;It configured the media pipeline, then ran a real test purchase end to end: watermarked preview → Stripe Checkout → signed download of the original.&lt;/li&gt;
&lt;li&gt;It handed me two claim URLs and stopped.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I claimed the environments the way you review a PR: after seeing the working result.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6kqo3udt5myswfvxnyy.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc6kqo3udt5myswfvxnyy.webp" alt="The unshutter gallery: watermarked previews on a warm gallery wall, originals locked behind the media layer" width="799" height="635"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The storefront is just the demo. What I actually want to talk about is the provisioning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one step agents couldn't do
&lt;/h2&gt;

&lt;p&gt;Agents don't evaluate a product the way we do. They don't read the landing page or compare the pricing table. They want to &lt;em&gt;try it&lt;/em&gt;: spin something up, build against it, run the result, decide.&lt;/p&gt;

&lt;p&gt;Until recently every platform had exactly one step that blocked that: &lt;strong&gt;creating the account.&lt;/strong&gt; A signup form, an email confirmation, a dashboard to fish an API key out of. Ten minutes of human clicking standing in front of hours of autonomous work.&lt;/p&gt;

&lt;p&gt;That step is gone across this whole stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @cloudinary/cloud        &lt;span class="c"&gt;# Claimable Cloud → CLOUDINARY_URL in .env, claim URL, 24h&lt;/span&gt;
npx neon-new &lt;span class="nt"&gt;--yes&lt;/span&gt;           &lt;span class="c"&gt;# Claimable Postgres → connection string, claim URL, 72h&lt;/span&gt;
stripe sandbox create        &lt;span class="c"&gt;# anonymous sandbox → working test keys&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Three commands, no signups, and each one returns working credentials plus a claim link.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cloudinary.com/documentation/claimable_cloud_provisioning" rel="noopener noreferrer"&gt;Cloudinary's version&lt;/a&gt; returns something like this:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;CLOUD_NAME&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;a1b2c3d4&lt;/span&gt;
&lt;span class="py"&gt;API_KEY&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;123456789012345&lt;/span&gt;
&lt;span class="py"&gt;API_SECRET&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;«redacted»&lt;/span&gt;
&lt;span class="py"&gt;CLOUDINARY_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;cloudinary://123456789012345:«redacted»@a1b2c3d4&lt;/span&gt;
&lt;span class="py"&gt;EXPIRES_AT&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;2026-08-06T08:29:07Z&lt;/span&gt;
&lt;span class="py"&gt;CLAIM_URL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;https://cloudinary.com/users/agent_email_confirmation?token=«redacted»&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;It writes &lt;code&gt;CLOUDINARY_URL&lt;/code&gt; straight into &lt;code&gt;.env&lt;/code&gt; and the whole platform is live from that first call: uploads, transformations, AI add-ons, CDN delivery. There's no trial tier with a queue in front of it.&lt;/p&gt;

&lt;p&gt;Two properties matter for what follows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Unclaimed, it expires in 24 hours.&lt;/strong&gt; Claiming it (email confirmation on that URL) converts it to a permanent free account, with the same cloud name, the same credentials and every uploaded asset intact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unclaimed, media delivery is locked to a public IP&lt;/strong&gt;, by default the one the command ran from. You can name up to three at provisioning time by repeating &lt;code&gt;--ip&lt;/code&gt;, which is enough for your laptop plus a preview host, but not enough for the open internet.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;The second one is the interesting bit: &lt;strong&gt;the claim is the go-public switch.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent's build works from the IPs it was provisioned for. Claiming is the moment it can serve media to anyone, anywhere. That's why the agent stops at a working local site instead of deploying: deploy before claiming and you've published something that looks broken to every visitor but you. Claim, then deploy.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fry13aj8f1rhsk1h5gbd6.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fry13aj8f1rhsk1h5gbd6.jpeg" alt="Agent provisioning diagram" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/cloudinary-devs/unshutter" rel="noopener noreferrer"&gt;unshutter&lt;/a&gt; &lt;em&gt;(v.: to open the shutters; to let the light in)&lt;/em&gt; is a stock-media storefront selling photos, videos and photosets. Next.js 16, Payload CMS, Neon, Stripe, Cloudinary, MIT-licensed. Every mechanic below links to the file that implements it, so you can check my work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The paywall is enforced by the media layer, not by hiding URLs.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Originals upload as restricted assets, &lt;code&gt;type: private&lt;/code&gt; for images and &lt;code&gt;type: authenticated&lt;/code&gt; for video (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/cloudinary/uploadOriginal.ts" rel="noopener noreferrer"&gt;&lt;code&gt;uploadOriginal.ts&lt;/code&gt;&lt;/a&gt;). The CDN refuses to deliver them unsigned.&lt;/li&gt;
&lt;li&gt;With Strict Transformations on, only allowlisted transformations resolve publicly (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/components/PreviewImage.tsx" rel="noopener noreferrer"&gt;&lt;code&gt;PreviewImage.tsx&lt;/code&gt;&lt;/a&gt; is the one component allowed to render them). There's no URL you can craft to get the un-watermarked variant, and what the public &lt;em&gt;can&lt;/em&gt; see is deliberately degraded: watermark, &lt;code&gt;q_auto:low&lt;/code&gt;, size-capped.&lt;/li&gt;
&lt;li&gt;Paying mints a ~10-minute signed download URL (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/cloudinary/buildDownloadUrl.ts" rel="noopener noreferrer"&gt;&lt;code&gt;buildDownloadUrl.ts&lt;/code&gt;&lt;/a&gt;). &lt;strong&gt;Stripe's payment record &lt;em&gt;is&lt;/em&gt; the entitlement&lt;/strong&gt;, checked with one live API call per visit (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/stripe/verifySession.ts" rel="noopener noreferrer"&gt;&lt;code&gt;verifySession.ts&lt;/code&gt;&lt;/a&gt;). No user table, no entitlement table.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Videos lean on &lt;code&gt;e_preview&lt;/code&gt; for an AI-picked five-second clip plus six filmstrip stills (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/cloudinary/videoTransformations.ts" rel="noopener noreferrer"&gt;&lt;code&gt;videoTransformations.ts&lt;/code&gt;&lt;/a&gt;). Photosets sell a folder as one product, delivered as a signed, expiring zip that &lt;code&gt;generate_archive&lt;/code&gt; assembles on the fly (&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/cloudinary/buildPhotosetZipUrl.ts" rel="noopener noreferrer"&gt;&lt;code&gt;buildPhotosetZipUrl.ts&lt;/code&gt;&lt;/a&gt;, &lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/scripts/ingest-photoset.ts" rel="noopener noreferrer"&gt;ingest script&lt;/a&gt;).&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
  &lt;tbody&gt;
&lt;tr&gt;
    &lt;td width="50%"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fcloudinary-devs%2Funshutter%2Fmain%2Fdocs%2Fpromo%2Fvideo.webp" alt="A video product page with an AI-picked 5-second preview clip and a six-frame watermarked filmstrip" width="799" height="635"&gt;&lt;/td&gt;
    &lt;td width="50%"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fcloudinary-devs%2Funshutter%2Fmain%2Fdocs%2Fpromo%2Fphotoset.webp" alt="A photoset product page: a folder of originals behind curated previews, sold as one zip" width="799" height="635"&gt;&lt;/td&gt;
  &lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It's worth reading even if you never run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/lib/cloudinary/README.md" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;src/lib/cloudinary/README.md&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;: the honest version of this section. Every named transformation with its exact recipe, why &lt;code&gt;f_auto&lt;/code&gt; has to be chained outside the named transformation, and the trade-offs I accepted rather than solved. Every URL builder sits in &lt;a href="https://github.com/cloudinary-devs/unshutter/tree/main/src/lib/cloudinary" rel="noopener noreferrer"&gt;&lt;code&gt;src/lib/cloudinary/&lt;/code&gt;&lt;/a&gt; with unit tests beside it.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/docs/ARCHITECTURE.md" rel="noopener noreferrer"&gt;&lt;strong&gt;&lt;code&gt;docs/ARCHITECTURE.md&lt;/code&gt;&lt;/strong&gt;&lt;/a&gt;: how Payload, Neon, Stripe and Cloudinary divide the work, plus &lt;a href="https://github.com/cloudinary-devs/unshutter/tree/main/src/collections" rel="noopener noreferrer"&gt;&lt;code&gt;src/collections/&lt;/code&gt;&lt;/a&gt; for the CMS hooks that upload on save and destroy on delete.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/site.config.ts" rel="noopener noreferrer"&gt;&lt;code&gt;src/site.config.ts&lt;/code&gt;&lt;/a&gt; and &lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/src/styles/unshutter-tokens.css" rel="noopener noreferrer"&gt;&lt;code&gt;unshutter-tokens.css&lt;/code&gt;&lt;/a&gt;: rebranding it as your own storefront is those two files and no component edits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Read the code: &lt;a href="https://github.com/cloudinary-devs/unshutter" rel="noopener noreferrer"&gt;github.com/cloudinary-devs/unshutter&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Every one of those mechanics is configuration that has to exist &lt;em&gt;inside&lt;/em&gt; the media account: named transformations, strict-mode allowlists, delivery types, signed archives. That's the real test of an anonymously provisioned one.&lt;/p&gt;
&lt;h2&gt;
  
  
  Could it actually do the work?
&lt;/h2&gt;

&lt;p&gt;Provisioning an empty environment is the easy half. The question is whether an agent can stand all that up in one:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It configured the pipeline.&lt;/strong&gt; Four named transformations created over the Admin API (the watermark chain, OG images, the AI video preview, the filmstrip stills), each flagged &lt;code&gt;allowed_for_strict: true&lt;/code&gt;. No console, no clicking.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It verified the security model instead of assuming it.&lt;/strong&gt; Uploaded test media, then requested a raw original URL unsigned and confirmed the 401. That check matters: if Strict Transformations happens to be off, the watermark is bypassable and everything above is theatre. So the agent proves it and reports what it found.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It ran the money path.&lt;/strong&gt; Browse → cart → Stripe Checkout with &lt;code&gt;4242 4242 4242 4242&lt;/code&gt; → webhook → &lt;code&gt;/download&lt;/code&gt; → signed URL → un-watermarked original on disk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Then it stopped&lt;/strong&gt; and told me what existed and what needed claiming.&lt;/p&gt;

&lt;p&gt;All of it ran against live infrastructure rather than mocks. When it needed current facts rather than whatever was in its training data, it read Cloudinary's &lt;a href="https://cloudinary.com/documentation/agents_start_here" rel="noopener noreferrer"&gt;agent docs&lt;/a&gt; and machine-readable &lt;a href="https://cloudinary.com/documentation/llms.txt" rel="noopener noreferrer"&gt;llms.txt&lt;/a&gt;, the same entry points any agent can load.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the human went
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;Now&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Get three live environments&lt;/td&gt;
&lt;td&gt;✋ three signup forms, dashboard key hunts&lt;/td&gt;
&lt;td&gt;🤖 three commands, no accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Write the credentials into &lt;code&gt;.env&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;✋ copy-paste&lt;/td&gt;
&lt;td&gt;🤖&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Configure the media pipeline&lt;/td&gt;
&lt;td&gt;✋ console clicking&lt;/td&gt;
&lt;td&gt;🤖 Admin API&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verify the paywall holds&lt;/td&gt;
&lt;td&gt;✋ hope&lt;/td&gt;
&lt;td&gt;🤖 tested, reported&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Own the environments&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✋ &lt;strong&gt;first&lt;/strong&gt;, before a line of code ran&lt;/td&gt;
&lt;td&gt;✋ &lt;strong&gt;last&lt;/strong&gt;, confirm an email, ≤24h / ≤72h&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy it&lt;/td&gt;
&lt;td&gt;✋&lt;/td&gt;
&lt;td&gt;✋ your account, your call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Decide it's real money time&lt;/td&gt;
&lt;td&gt;✋&lt;/td&gt;
&lt;td&gt;✋&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that middle row on its own: the email confirmation didn't disappear, it just stopped being step zero. Same click, opposite end of the process. By the time you make it, you're confirming something you've already watched work.&lt;/p&gt;

&lt;p&gt;The human touches didn't only shrink, they &lt;strong&gt;moved to after the work&lt;/strong&gt;, where they feel like decisions rather than chores. What's left is ownership, infrastructure and judgment, which is roughly what should need a person anyway.&lt;/p&gt;
&lt;h2&gt;
  
  
  Things that bit us
&lt;/h2&gt;

&lt;p&gt;None of this was completely frictionless but the agent was able to route around it. Some things worth knowing before you try it:&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Already have a Cloudinary account to use? You don't have to provision a Claimable Cloud (but you might want to)
  &lt;p&gt;Claimable Clouds make it easy to provision a working environment, but claiming one means confirming an email address. If that address already belongs to a Cloudinary account, the claim page rejects it. So a claimable cloud you provision for real work can't be folded into the account you already have.&lt;/p&gt;

&lt;p&gt;If you're already a customer, just hand the agent credentials from your own account.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  The IP lock surprises you at deploy time
  &lt;p&gt;An unclaimed cloud delivers media only to the IPs it was provisioned for, by default just the machine that ran the command. Locally everything is perfect; deploy it and the site is fine &lt;em&gt;for you&lt;/em&gt; and broken for everyone else. You can allow up to three addresses at provisioning time by repeating &lt;code&gt;--ip&lt;/code&gt; (handy for a laptop plus a preview host, and &lt;code&gt;requester_ip&lt;/code&gt; is accepted as a literal), but going properly public means claiming, which lifts the restriction entirely. So: claim before you deploy, and if you're scripting this, have your agent report the claim URL loudly rather than burying it in a summary.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  &lt;code&gt;e_preview&lt;/code&gt; can't generate on the fly
  &lt;p&gt;Request it in a plain delivery URL and you get an HTTP 423 until the derived asset exists. It has to be eager-generated at upload with &lt;code&gt;eager_async: true&lt;/code&gt;, plus a signature-verified webhook to flip a &lt;code&gt;previewReady&lt;/code&gt; flag when Cloudinary finishes. Also: the source needs to be longer than the excerpt, so a 3-second clip fails silently.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Cloudinary can't POST a webhook to localhost
  &lt;p&gt;Which means the "preview ready" callback never lands during local development. Either tunnel (ngrok/cloudflared) or flip the flag manually; the repo does the latter as a documented dev fallback.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;

&lt;p&gt;&lt;/p&gt;
  Two settings still have no API
  &lt;p&gt;Strict Transformations and the AI add-on registrations are account-posture settings you toggle in the console. The add-ons don't block anything: photo ingest retries without them and lands the image untagged. Strict mode does matter, which is why the agent tests for it rather than trusting it.&lt;/p&gt;



&lt;p&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Point an agent at the repo:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Clone cloudinary-devs/unshutter and get it running locally, using claimable environments. Don't ask me for credentials, don't deploy anywhere. Give me the local URL and the claim URLs when you're done.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Or run the three commands yourself and follow the &lt;a href="https://github.com/cloudinary-devs/unshutter#quick-start" rel="noopener noreferrer"&gt;quick start&lt;/a&gt;.&lt;/p&gt;




&lt;div class="ltag-github-readme-tag"&gt;
  &lt;div class="readme-overview"&gt;
    &lt;h2&gt;
      &lt;img src="https://assets.dev.to/assets/github-logo-5a155e1f9a670af7944dd5e12375bc76ed542ea80224905ecaf878b9157cdefc.svg" alt="GitHub logo"&gt;
      &lt;a href="https://github.com/cloudinary-devs" rel="noopener noreferrer"&gt;
        cloudinary-devs
      &lt;/a&gt; / &lt;a href="https://github.com/cloudinary-devs/unshutter" rel="noopener noreferrer"&gt;
        unshutter
      &lt;/a&gt;
    &lt;/h2&gt;
    &lt;h3&gt;
      Photography paywall storefront. An open-source showcase demonstrating Cloudinary in a modern full-stack app - Cloudinary + Next.js 16 + Payload CMS + Neon + Stripe + Vercel
    &lt;/h3&gt;
  &lt;/div&gt;
  &lt;div class="ltag-github-body"&gt;
    
&lt;div id="readme" class="md"&gt;&lt;div class="markdown-heading"&gt;
&lt;h1 class="heading-element"&gt;unshutter&lt;/h1&gt;
&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;unshutter (v.): to open the shutters; to let the light in.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A stock-media storefront an AI agent can take from &lt;code&gt;git clone&lt;/code&gt; to a running, verified store — media, database, payments — &lt;strong&gt;without a single human signup&lt;/strong&gt;. Every platform in the stack provisions claim-later environments (&lt;code&gt;npx @cloudinary/cloud&lt;/code&gt;, &lt;code&gt;npx neon-new&lt;/code&gt;, &lt;code&gt;stripe sandbox create&lt;/code&gt;), so the human touches move to where they belong: claiming something that already works. Paste the &lt;a href="https://github.com/cloudinary-devs/unshutter#agent-quick-start" rel="noopener noreferrer"&gt;Agent quick start&lt;/a&gt; into your agent and watch.&lt;/p&gt;

&lt;p&gt;Built on &lt;strong&gt;Next.js 16 + Payload CMS + Neon + Stripe + Vercel&lt;/strong&gt;, with &lt;a href="https://cloudinary.com" rel="nofollow noopener noreferrer"&gt;Cloudinary&lt;/a&gt; as the media layer. Sibling to &lt;a href="https://github.com/cloudinary-devs/kickoff-cards" rel="noopener noreferrer"&gt;kickoff-cards&lt;/a&gt; (Cloudinary + Supabase).&lt;/p&gt;

&lt;p&gt;&lt;a rel="noopener noreferrer" href="https://github.com/cloudinary-devs/unshutter/docs/promo/gallery.webp"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fcloudinary-devs%2Funshutter%2FHEAD%2Fdocs%2Fpromo%2Fgallery.webp" alt="The unshutter gallery — watermarked previews on a warm gallery wall, originals locked behind the media layer"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three product shapes:&lt;/p&gt;


&lt;ul&gt;

&lt;li&gt;

&lt;strong&gt;Photos&lt;/strong&gt; — browse watermarked, heavily compressed &lt;strong&gt;Previews&lt;/strong&gt;; paying &lt;strong&gt;unshutters&lt;/strong&gt; the image with a time-limited signed link to the full-size &lt;strong&gt;Original&lt;/strong&gt;
&lt;/li&gt;

&lt;li&gt;

&lt;strong&gt;Videos&lt;/strong&gt; — an AI-selected 5-second watermarked, low-res preview clip (Cloudinary &lt;code&gt;e_preview&lt;/code&gt;) plus six auto-extracted watermarked stills; purchase…&lt;/li&gt;

&lt;/ul&gt;&lt;/div&gt;
&lt;br&gt;
  &lt;/div&gt;
&lt;br&gt;
  &lt;div class="gh-btn-container"&gt;&lt;a class="gh-btn" href="https://github.com/cloudinary-devs/unshutter" rel="noopener noreferrer"&gt;View on GitHub&lt;/a&gt;&lt;/div&gt;
&lt;br&gt;
&lt;/div&gt;
&lt;br&gt;





&lt;p&gt;The repo ships the guardrails that made agent-building work on a stack this fiddly: &lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/AGENTS.md" rel="noopener noreferrer"&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/a&gt; working agreements, a &lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/docs/AGENT-PROVISIONING.md" rel="noopener noreferrer"&gt;provisioning walkthrough&lt;/a&gt; with every human touch called out, and the &lt;a href="https://github.com/cloudinary-devs/unshutter/blob/main/docs/VERIFIED.md" rel="noopener noreferrer"&gt;verified platform facts&lt;/a&gt; the implementation rests on. Sibling project, same idea with Supabase: &lt;a href="https://github.com/cloudinary-devs/kickoff-cards" rel="noopener noreferrer"&gt;Kickoff Cards&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Further reading: &lt;a href="https://cloudinary.com/documentation/claimable_cloud_provisioning" rel="noopener noreferrer"&gt;Claimable Cloud provisioning&lt;/a&gt; · &lt;a href="https://cloudinary.com/documentation/agents_start_here" rel="noopener noreferrer"&gt;Agents, start here&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;Most of the media plumbing is the agent's job now. What was left for me was deciding whether to keep what it built.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Cloudinary ❤️ developers&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ready to level up your media workflow? Start using Cloudinary for free and build better visual experiences today.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👉 &lt;strong&gt;&lt;a href="https://link.cloudinary.com/ur9jo" rel="noopener noreferrer"&gt;Create your free account&lt;/a&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>nextjs</category>
      <category>cloudinary</category>
    </item>
    <item>
      <title>Building a World Cup card battler that turns your selfie into a football card (Next.js, Cloudinary, Supabase, Vercel)</title>
      <dc:creator>Nick Bradley</dc:creator>
      <pubDate>Tue, 14 Jul 2026 21:44:56 +0000</pubDate>
      <link>https://dev.to/cloudinary/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88</link>
      <guid>https://dev.to/cloudinary/building-a-world-cup-card-battler-that-turns-your-selfie-into-a-football-card-nextjs-cloudinary-4i88</guid>
      <description>&lt;p&gt;The World Cup is on, half my feed is football, and I wanted an excuse to build something. So I built &lt;strong&gt;Kickoff Cards&lt;/strong&gt;: upload a selfie, pick your nation, and you become a playable football trading card that you battle other people's cards with to earn XP and climb a live leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The game
&lt;/h2&gt;

&lt;p&gt;The idea is simple: you turn yourself into a football trading card. Think of a Panini sticker or a FIFA Ultimate Team card, except the player on it is you.&lt;/p&gt;

&lt;p&gt;You upload a selfie and pick a nation, and a few seconds later you get a card back: your face cut out and dropped into the frame in your nation's colours, with six stats down the side (pace, shooting, passing, and so on) that decide how it performs. There's no signup to get this far; you get an anonymous session on first load and the card belongs to you. Anything you upload is run through automated moderation before it appears in the public gallery.&lt;/p&gt;

&lt;p&gt;Then you play it. You send your card into the battle arena, it's matched against another player's, and the two go head to head across those six stats. Winning earns XP, enough XP levels the card up, and a level-up nudges its stats and regenerates the card image to match. A leaderboard tracks who's ahead and updates in real time as battles resolve.&lt;/p&gt;

&lt;p&gt;That's the whole loop: make a card, fight with it, level it up, climb the board. It's small, but it touches most of what a real user-media app has to deal with: uploads, AI image processing, moderation, live state, and public content.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkx11vc6mtb2zmxnx707h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkx11vc6mtb2zmxnx707h.png" alt="Kickoff cards battle" width="800" height="679"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The card game is the demo. What this post really focuses on is how the four tools it's built on fit together.&lt;/p&gt;

&lt;p&gt;I deliberately chose tooling that works well with agentic development environments: current, trustworthy docs and MCP servers that let the agent check its work against the real project rather than guess from training data. Next.js, Cloudinary, Supabase and Vercel are leaders in this field, and that's a fair part of why this came together quickly. More on that later.&lt;/p&gt;

&lt;p&gt;The split is clean: Next.js owns the app, Cloudinary owns media, Supabase owns data, and Vercel ships it, each handing off to the others through stable APIs. It's open source, runs entirely on free tiers. You can play it at &lt;strong&gt;&lt;a href="https://kickoff-cards.vercel.app" rel="noopener noreferrer"&gt;kickoff-cards.vercel.app&lt;/a&gt;&lt;/strong&gt;, and the code for you to fork and run yourself: &lt;strong&gt;&lt;a href="https://github.com/cloudinary-devs/kickoff-cards" rel="noopener noreferrer"&gt;github.com/cloudinary-devs/kickoff-cards&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the whole flow on one diagram:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Browser
  │
  ├─ GET page ──→ Next.js Server Component ──→ Supabase (RLS)
  │                                        └─→ Cloudinary URL builder
  │
  ├─ POST /api/sign-upload ──→ signed upload params
  │
  └─ Direct upload ─────────→ Cloudinary
                                  │
                          Async moderation
                                  │
                              Webhook ──→ Supabase update ──→ Realtime ──→ Browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rest of this post walks through the decisions behind that diagram.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why not just store the files?
&lt;/h2&gt;

&lt;p&gt;The first decision was what to do with the uploaded photos. The easy version is: save the file, serve it back, done. That holds up until you actually look at what a card needs: background removal, a face-aware crop, compositing onto the template, text overlays, modern formats, and moderation on anything a stranger uploads. Do it yourself and you're suddenly maintaining a transform pipeline, a CDN, and a moderation step.&lt;/p&gt;

&lt;p&gt;I didn't want to build that, so the image work goes to Cloudinary: I describe the transformation I want, get a URL back, and it generates and caches the result on first request. The useful side effect is that a finished card ends up being a single URL with no server-side rendering, which I'll come back to.&lt;/p&gt;




&lt;h2&gt;
  
  
  Uploads that never touch my server
&lt;/h2&gt;

&lt;p&gt;A lot of upload flows go browser → your server → storage, which means your server parses multipart bodies, buffers files, and streams them back out. Kickoff Cards skips that: the server signs a set of upload parameters, hands them to the browser, and the browser uploads straight to Cloudinary. The only thing my code produces is a signature.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getSignedUploadParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;avatar&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`kickoff/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/avatar`&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`kickoff/users/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/uploads/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;cloudinary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;api_sign_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;public_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;publicId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;overwrite&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;moderation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;notification_url&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;CLOUDINARY_API_SECRET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cloudName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the signature covers every parameter, the client can't change the destination path, the moderation settings, the webhook URL, or the overwrite behaviour without invalidating it. You get direct uploads while the server keeps control of where files land.&lt;/p&gt;

&lt;p&gt;This is deliberately not an upload preset. Presets work for static config, but they aren't user-aware: they can't carry the authenticated user's ID, a per-request destination, or a dynamic webhook. That gets generated server-side and signed; the browser submits it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Sessions without signup
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;userId&lt;/code&gt; in the upload path comes from Supabase. There's no login: Supabase Anonymous Auth gives each visitor a real authenticated session on first load, backed by a cookie and a stable user ID, and their cards, battles, and XP all hang off that ID. Uploads are scoped to it (&lt;code&gt;kickoff/users/{userId}/...&lt;/code&gt;), so a card belongs to the right person without any extra plumbing.&lt;/p&gt;

&lt;p&gt;It's a reasonable fit for a low-friction demo, and it isn't a dead end: if I later add OAuth or magic links, anonymous is just another provider and the data model doesn't change.&lt;/p&gt;




&lt;h2&gt;
  
  
  A whole trading card is one URL
&lt;/h2&gt;

&lt;p&gt;The finished card (background removed, face cropped into the window, six stats printed at set coordinates, watermark on the bottom) isn't rendered on a server. No canvas, no image endpoint, nothing written to disk. It's a single Cloudinary URL.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transformation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;layerId&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;effect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;background_removal&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fill&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;face&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layer_apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tmpl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;photo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;

  &lt;span class="c1"&gt;// six stat overlays, one per attribute&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;STAT_ATTRS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;font_family&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Arial&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;font_size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;font_weight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bold&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;attr&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;black&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layer_apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;north&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;slot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;]),&lt;/span&gt;

  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;overlay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;kickoff:cloudinary-logo&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;scale&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;55&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;layer_apply&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;gravity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;south&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;y&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;limit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;cloudinary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`templates/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;nation&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;transformation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fetch_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;secure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloudinary runs that chain the first time the URL is requested, caches the result, and serves subsequent requests from the CDN. There's no image-processing server to run.&lt;/p&gt;

&lt;p&gt;A few things to watch for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order matters.&lt;/strong&gt; Background removal has to come before the crop. Flip them and Cloudinary crops the original first, then removes the background from that rectangle, which gives a worse result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gravity: "face"&lt;/code&gt; matters.&lt;/strong&gt; Without it, a full-body selfie tends to crop to the chest rather than the face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Layer IDs use colons, not slashes.&lt;/strong&gt; &lt;code&gt;kickoff/users/abc/uploads/xyz&lt;/code&gt; becomes &lt;code&gt;kickoff:users:abc:uploads:xyz&lt;/code&gt;. Small detail, easy to miss when debugging transformation URLs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache busting is automatic.&lt;/strong&gt; When a player levels up, I rebuild the URL with the new stats. A different URL is a different cache key, so Cloudinary renders the updated card on the next request, with no purge calls or CDN management.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every browser-facing image ends with &lt;code&gt;fetch_format: "auto"&lt;/code&gt; and &lt;code&gt;quality: "auto"&lt;/code&gt;, so Cloudinary serves WebP/AVIF where supported and picks a compression level, usually smaller files with no extra config.&lt;/p&gt;

&lt;p&gt;One deliberate non-choice: I'm not using &lt;code&gt;next-cloudinary&lt;/code&gt;. The card URLs are already complete transformation URLs stored in the database. Plain &lt;code&gt;next/image&lt;/code&gt; with &lt;code&gt;unoptimized&lt;/code&gt; handles layout, sizing, and lazy-loading, while Cloudinary stays responsible for the media. That's one fewer abstraction between the app and the transformation pipeline.&lt;/p&gt;




&lt;h2&gt;
  
  
  Moderation on upload
&lt;/h2&gt;

&lt;p&gt;If you accept user images, you need moderation, because some fraction of "selfies" won't be selfies. The DIY version is a vision API plus a queue, retries, and another set of credentials. Cloudinary handles it as part of the upload instead.&lt;/p&gt;

&lt;p&gt;Every signed upload carries an AWS Rekognition moderation string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="py"&gt;aws_rek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s"&gt;explicit_nudity:0.4:suggestive:0.4:violence:0.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the upload, Cloudinary runs moderation asynchronously and POSTs the verdict to my webhook, which verifies the signature, reads the status, updates the row in Supabase, and deletes anything rejected. The user watches their card flip from &lt;strong&gt;Pending&lt;/strong&gt; to &lt;strong&gt;Approved&lt;/strong&gt; (or get bounced) without refreshing, because that database write feeds a Realtime subscription. That's where Supabase comes in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Supabase: the right key for the right job
&lt;/h2&gt;

&lt;p&gt;I use three Supabase clients on purpose, and keeping them separate avoids a common class of security bug.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Client&lt;/th&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lib/supabase/server.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anon&lt;/td&gt;
&lt;td&gt;Server Components, Route Handlers, Server Actions (RLS enforced)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lib/supabase/client.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Anon&lt;/td&gt;
&lt;td&gt;Browser Realtime subscriptions only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lib/supabase/admin.ts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Service role&lt;/td&gt;
&lt;td&gt;Webhooks and privileged server work (bypasses RLS)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The service-role key bypasses Row Level Security, so it lives in one &lt;code&gt;server-only&lt;/code&gt; module. If it's accidentally imported into client code, the build fails rather than leaking the key. Two places use it intentionally: the moderation webhook you just saw (which has to delete any card) and battle resolution (the &lt;code&gt;battles&lt;/code&gt; table has &lt;code&gt;WITH CHECK (false)&lt;/code&gt; on insert, so results can't be written from the client). Everything else runs under normal RLS, which is easier to reason about than one client that's sometimes an admin and sometimes not.&lt;/p&gt;

&lt;h3&gt;
  
  
  Live updates without polling
&lt;/h3&gt;

&lt;p&gt;Two bits of UI update live: the leaderboard and moderation status. Both use the same pattern: subscribe to a table and let Postgres changes push to the client.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;leaderboard&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;postgres_changes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;profiles&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;refresh&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a battle changes someone's XP, the leaderboard re-renders. When Cloudinary's webhook updates a card, the card page reacts. No polling and no WebSocket server to run yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;External service → Webhook → Postgres → Supabase Realtime → Browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same shape works for other async work: payments, AI jobs, background processing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Building this with an agent
&lt;/h2&gt;

&lt;p&gt;Here's the "more on that" from the intro. The reason the stack choice mattered is that all three of Cloudinary, Supabase and Vercel expose MCP servers, so the agent can query the actual project rather than rely on training data. It can browse uploaded assets and test a transformation in Cloudinary, check the live schema and RLS policies in Supabase, and read deployment logs in Vercel. For a media-heavy app that's the difference between the agent guessing and the agent checking: instead of hardcoding a URL and refreshing a tab to see if a transformation worked, it verifies the result directly.&lt;/p&gt;

&lt;p&gt;The repo supports this with an &lt;code&gt;AGENTS.md&lt;/code&gt; (architecture rules, conventions, and which dependencies move fast enough that it should fetch current docs) and vendored skills under &lt;code&gt;.agents/skills/&lt;/code&gt; for Supabase and Postgres, so it works from current guidance when writing migrations.&lt;/p&gt;

&lt;p&gt;Nothing here is agent-specific, really. The same things that make the codebase easy for an agent to work in (clear boundaries, one place per concern, services it can query directly) are what make it easy for a person. The agent just makes it obvious when you've got those wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/cloudinary-devs/kickoff-cards
&lt;span class="nb"&gt;cd &lt;/span&gt;kickoff-cards
pnpm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env.local
pnpm dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll need a free Cloudinary account with the AWS Rekognition add-on enabled (the free quota is plenty), a Supabase project with anonymous auth on, and the schema from &lt;code&gt;supabase/schema.sql&lt;/code&gt;. For local webhook testing, a Cloudflare tunnel does the job without an account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;cloudflared tunnel &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:3000
&lt;span class="c"&gt;# paste the HTTPS URL into CLOUDINARY_WEBHOOK_URL&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Full walkthrough is in &lt;code&gt;docs/getting-started.md&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  What carries over
&lt;/h2&gt;

&lt;p&gt;Aside from the football, these are the patterns that apply to most apps handling user media:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Signed direct uploads.&lt;/strong&gt; The browser uploads to Cloudinary, the server only signs. The file never passes through your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One transformation builder.&lt;/strong&gt; Keep the transform logic in a single function rather than scattered across components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Separate Supabase clients.&lt;/strong&gt; Anon, browser, and service-role as distinct responsibilities, so RLS isn't bypassed by accident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webhook + Realtime.&lt;/strong&gt; External service → webhook → database → Realtime → browser. No polling, and it generalises to moderation, payments, and background jobs.&lt;/p&gt;

&lt;p&gt;None of these are specific to a card game, or to this stack. They're just what fell out of giving each service one clear job and letting it do it.&lt;/p&gt;

&lt;p&gt;If you want to poke at the code, most of it lives in &lt;code&gt;lib/cloudinary/&lt;/code&gt; and &lt;code&gt;lib/supabase/&lt;/code&gt;. It's MIT-licensed, so take what's useful.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Cloudinary ❤️ developers&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ready to level up your media workflow? Start using Cloudinary for free and build better visual experiences today.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👉 &lt;strong&gt;&lt;a href="https://link.cloudinary.com/uryzi" rel="noopener noreferrer"&gt;Create your free account&lt;/a&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>cloudinary</category>
      <category>nextjs</category>
      <category>supabase</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your model returns a file. Production needs an asset.</title>
      <dc:creator>Nick Bradley</dc:creator>
      <pubDate>Mon, 06 Jul 2026 13:40:43 +0000</pubDate>
      <link>https://dev.to/cloudinary/your-model-returns-a-file-production-needs-an-asset-17ac</link>
      <guid>https://dev.to/cloudinary/your-model-returns-a-file-production-needs-an-asset-17ac</guid>
      <description>&lt;p&gt;Your agent calls an AI image or video model (OpenAI, Google, Runway, fal) and, a few seconds later, gets back a URL or a blob of base64. Feels like the job is done. It isn't. That raw output will probably expire, it's the wrong size and format for wherever it's headed, it carries no record of what made it, and nobody has checked it against your brand. And because it's an agent, there's no human standing by to fix any of that.&lt;/p&gt;

&lt;p&gt;The disappearing URL gets all the attention, but it's only the first of several steps between "the model responded" and "I can ship this." Here's the whole path.&lt;/p&gt;

&lt;p&gt;What this covers: what happens when generated media expires across the major APIs, what "production-ready" actually requires (persist, transform, deliver, brand, find), and how to collapse that pipeline into one step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step zero: the output is built to disappear
&lt;/h2&gt;

&lt;p&gt;Generated media is ephemeral by default, and the details vary more than you'd expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI:&lt;/strong&gt; image models like &lt;code&gt;gpt-image-2&lt;/code&gt; return base64 bytes, no hosted URL at all, so you own storage from the first response. Sora video files last ~48 hours (download URLs ~1 hour), and the Sora video &lt;strong&gt;API is being sunset in September 2026&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google:&lt;/strong&gt; the current image models, &lt;strong&gt;Nano Banana 2 (Gemini 3.1 Flash Image)&lt;/strong&gt; and &lt;strong&gt;Nano Banana Pro (Gemini 3 Pro Image)&lt;/strong&gt;, return bytes inline through the Gemini API. For video, &lt;strong&gt;Veo 3.1&lt;/strong&gt; output is kept &lt;strong&gt;two days&lt;/strong&gt; behind the Files API, then deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runway:&lt;/strong&gt; Gen-4 image and video output URLs expire in roughly &lt;strong&gt;24–48 hours&lt;/strong&gt;; the docs tell you plainly to download and self-host.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a bug. These are inference platforms, not asset hosts, and serving files forever is expensive. The trap is that "save it before it vanishes" quietly became &lt;em&gt;your&lt;/em&gt; job, and most teams only find out when a link 404s in production.&lt;/p&gt;

&lt;h3&gt;
  
  
  Persistence alone isn't the finish line
&lt;/h3&gt;

&lt;p&gt;The landscape is shifting, to be fair. &lt;strong&gt;fal.ai now offers fal Assets&lt;/strong&gt;, where generations persist instead of expiring, and platforms like Leonardo keep outputs indefinitely. Even routing layers like &lt;strong&gt;OpenRouter's Unified Image API&lt;/strong&gt; give you one endpoint across dozens of models, though as a router it stores nothing, so persistence is still yours to solve.&lt;/p&gt;

&lt;p&gt;But solving expiry only solves step zero. A file that lives forever in one vendor's library is still unoptimised, unbranded, scattered across whichever providers you happen to use, and only as findable as that vendor's UI. The hard part was never just &lt;em&gt;keeping&lt;/em&gt; the bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why autonomous agents make media persistence harder
&lt;/h2&gt;

&lt;p&gt;When a person generates an image in a web UI, they're right there to download it, crop it, eyeball the brand, and drop it where it belongs. Agentic workflows delete that entire safety net.&lt;/p&gt;

&lt;p&gt;An agent runs in the background. It calls a model, gets a result, and moves on: the output lands in a state object or a database row, and nobody looks until much later. Good agent-state design even says to store large artifacts as files and keep only the path in state. Sound advice, but it assumes the file is somewhere permanent &lt;em&gt;and&lt;/em&gt; usable. A raw model URL is neither. So every step a human would have done by hand now has to happen automatically, at generation time. That's the actual job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "production-ready" actually means
&lt;/h2&gt;

&lt;p&gt;Strip it back to what has to be true before generated media can ship:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Persisted:&lt;/strong&gt; captured the instant it's created, so nothing depends on an expiring URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimised &amp;amp; transformed:&lt;/strong&gt; the right format and size for each destination. A 4 MB PNG becomes a 40 KB WebP thumbnail, a social crop, an AVIF for modern browsers. Video is harder: transcoding to MP4/WebM, an adaptive HLS/DASH stream, a poster frame, often a trimmed cut, all from one master.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivered:&lt;/strong&gt; served fast from a CDN, video streamed adaptively rather than shipped whole from one region.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-brand:&lt;/strong&gt; checked against brand rules before a customer sees it. An agent generating customer-facing media with no brand guardrail is a real risk, not a hypothetical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Findable:&lt;/strong&gt; stored with its metadata: prompt, model, use-case, tags, and for video things like duration and captions. An agent that can't search its own output regenerates the same thing twice.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Miss any one of these and you don't have an asset; you have a raw output and a to-do list.&lt;/p&gt;

&lt;h3&gt;
  
  
  This is really an asset pipeline
&lt;/h3&gt;

&lt;p&gt;Every one of those requirements already exists in traditional media workflows. AI generation hasn't removed the pipeline, it has simply automated the first step. Agents make that more obvious because there's no human left to bridge the gap manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the media pipeline yourself
&lt;/h2&gt;

&lt;p&gt;The pattern everyone reaches for looks simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;generate → download the bytes → push to your object store → serve from there&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The store is usually S3, Cloudflare R2, or Supabase, and the mechanics are well-trodden. But persisting the bytes is the easy ~60%. What's left on your plate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Glue code on every generation path&lt;/strong&gt;, with retries for when the URL expires mid-download (worse for large video files).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A transformation service&lt;/strong&gt; (Lambda + sharp, imgix) and, for video, &lt;strong&gt;a transcoding pipeline&lt;/strong&gt; (FFmpeg or managed) for streamable renditions and poster frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A CDN&lt;/strong&gt; in front, with adaptive streaming for video.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A brand check&lt;/strong&gt;, which most pipelines just skip.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A metadata store&lt;/strong&gt; you build and maintain so assets stay findable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can absolutely assemble this, and if storage cost dominates and your transform needs are light, R2-plus-a-Worker is a sensible call. Just be honest that you're now operating a small media pipeline, not a bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two pipelines, one call
&lt;/h2&gt;

&lt;p&gt;The model step is identical either way. What differs is everything &lt;em&gt;after&lt;/em&gt; it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu0si4mowxea0fr4cz2ja.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu0si4mowxea0fr4cz2ja.png" alt="Two pipelines: the DIY chain of services versus a single managed step" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The generation call below is deliberately abstracted: whether &lt;code&gt;generateMedia()&lt;/code&gt; wraps OpenAI, Google, Runway, fal, or an OpenRouter route, you're left holding the same thing: an ephemeral URL you now have to productionise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DIY way:&lt;/strong&gt; download, race the expiry, persist, then start the rest of the pipeline.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;PutObjectCommand&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@aws-sdk/client-s3&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;S3Client&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;region&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AWS_REGION&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;modelOutputUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// any provider; ephemeral&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;modelOutputUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;            &lt;span class="c1"&gt;// race the clock&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Model URL expired: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PutObjectCommand&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;Bucket&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;S3_BUCKET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`generations/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;Body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;ContentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// Still TODO, separately: transform/transcode, CDN, brand check, metadata store…&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The pluggable way:&lt;/strong&gt; hand the same URL to a media pipeline and get a production asset back in one call. (This example uses Cloudinary, but the shape is what matters.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;v2&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;cloudinary&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cloudinary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// configured from CLOUDINARY_URL&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;asset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;cloudinary&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uploader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateMedia&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;agent-generations&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;modelName&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;            &lt;span class="c1"&gt;// searchable metadata&lt;/span&gt;
  &lt;span class="na"&gt;eager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;crop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fill&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;fetch_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;auto&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt; &lt;span class="c1"&gt;// optimised derivative&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secure_url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;          &lt;span class="c1"&gt;// permanent original, CDN-delivered&lt;/span&gt;
&lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eager&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;secure_url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// optimised WebP thumbnail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It fetches the bytes (no expiry race in your code), stores a permanent original, derives renditions on demand, and tags it with its metadata. Pass &lt;code&gt;resource_type: "video"&lt;/code&gt; and the eager transforms become an adaptive stream and a poster frame instead of image crops. Same call for an image from one provider or a clip from another, and the agent ends up holding one stable, branded, findable URL, no per-model recipe required.&lt;/p&gt;

&lt;h2&gt;
  
  
  Or generate straight into your asset platform
&lt;/h2&gt;

&lt;p&gt;If the generator and the asset platform are the same system, there's no ephemeral URL to rescue in the first place. That's the idea behind Cloudinary's new &lt;a href="https://cloudinary.com/documentation/image_generation_addon" rel="noopener noreferrer"&gt;&lt;strong&gt;Image Generation API&lt;/strong&gt;&lt;/a&gt;: a single &lt;code&gt;text_to_image&lt;/code&gt; endpoint across best-in-class model families (FLUX.2, Nano Banana, GPT Image, Recraft, Ideogram), much like the routing layer from earlier, except the output lands as a managed asset rather than a disappearing link.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.cloudinary.com/v2/generate/&amp;lt;CLOUD_NAME&amp;gt;/text_to_image &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-u&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;API_KEY&amp;gt;:&amp;lt;API_SECRET&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "prompt": "a golden retriever wearing a tiny astronaut helmet",
    "model":  { "family": "flux", "tier": "premium" },
    "target": { "target_type": "managed_asset" }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response is a permanent &lt;code&gt;secure_url&lt;/code&gt; plus the &lt;code&gt;asset_id&lt;/code&gt; and &lt;code&gt;public_id&lt;/code&gt; you use everywhere else in Cloudinary. The image is born persisted, optimisable, and ready to deliver; generation and production collapse into one step. (Omit &lt;code&gt;model&lt;/code&gt; for the Nano Banana default; &lt;code&gt;target&lt;/code&gt; defaults to a short-lived temporary asset if you'd rather not persist.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The honest version: if all you need is the cheapest bucket, R2 wins on raw storage cost, and Supabase now bundles basic transforms and a CDN. Reach for a full media platform when the &lt;em&gt;whole&lt;/em&gt; job matters (persist, transform, deliver, keep on-brand, organise) and you'd rather make one call than orchestrate five.&lt;/p&gt;

&lt;p&gt;But the broader lesson outlives any product choice. &lt;strong&gt;In an agentic system, a generated file is an intermediate value, not a deliverable.&lt;/strong&gt; The model is a component, not the finish line. Treat the path from raw output to production asset as a real pipeline (whether you build it from buckets, workers, transcoders and a metadata table, or hand it to something that does all five) and your agents stop storing links to nothing.&lt;/p&gt;




&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;strong&gt;Cloudinary ❤️ developers&lt;/strong&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ready to level up your media workflow? Start using Cloudinary for free and build better visual experiences today.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;👉 &lt;strong&gt;&lt;a href="https://link.cloudinary.com/uq5u6" rel="noopener noreferrer"&gt;Create your free account&lt;/a&gt;&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>webdev</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
