Cloudinary Next.js Upload Guide: Images in Your SaaS
A Cloudinary Next.js upload walkthrough for founders: signed uploads, the CldUploadWidget, server actions, free-tier limits, and the Claude Code prompt.
· Justin Boggs

The fastest way to add image uploads to a Next.js SaaS is Cloudinary's upload widget plus a signed-upload API route — about 30 minutes of work, most of which an AI assistant can do for you. Cloudinary handles storage, resizing, optimization, and delivery through a CDN; your app handles a button and a small signing endpoint. This guide walks the whole Cloudinary Next.js upload flow the way I wired it with Claude Code: account setup, environment variables, the CldUploadWidget component, signed uploads, saving the result with a server action, and what the free tier actually covers.
TL;DR
- Use the next-cloudinary package — its
CldUploadWidgetgives you a full upload UI (drag-drop, camera, URL) in one component.- Always use signed uploads in production: a tiny API route signs each request with your API secret so strangers can't fill your account.
- Cloudinary's free plan includes 25 credits/month — roughly 25 GB of storage/bandwidth or 25,000 transformations in any combination.
- The exact Claude Code prompt to wire all of this is near the end of this post.
Why image uploads are deceptively hard
Every SaaS eventually needs uploads — avatars, logos, product screenshots, receipts. And uploads are one of those features that looks like an afternoon and turns into a week, because the file input is maybe 5% of the job.
The real work is everything after the file leaves the browser. You need somewhere durable to store it. You need to resize it, because users will upload 12 MB phone photos and your dashboard does not need a 12 MB avatar. You need to convert formats, because the same user uploads HEIC from an iPhone and expects it to render everywhere. You need a CDN so images load fast outside your home country. And you need security, because an open upload endpoint is an invitation — to abuse, to illegal content, to someone using your storage bill as their personal Dropbox.
You can build all of that on raw storage like AWS S3. People do. You'll write presigned URL logic, an image-resizing pipeline (probably a Lambda), cache headers, and format negotiation by hand. As I argued in the stack anatomy post, the winning move for a solo founder is usually to buy the boring-but-deep infrastructure and spend your building energy on the product itself.
That's the case for Cloudinary in one sentence: Cloudinary is a media platform that stores, transforms, optimizes, and delivers images through a CDN, controlled entirely by URL parameters and a generous SDK. Upload once, then request any size, crop, or format by changing the URL. No pipeline to maintain. For a non-technical founder, that trade is hard to beat — and the integration is genuinely AI-assistant-friendly — the whole thing reduces to a prompt, which we'll get to.
Setting up Cloudinary and your environment variables
Start with a free Cloudinary account — no card required. Once you're in the dashboard, you need four values, and it's worth knowing exactly where each lives because the dashboard has grown a lot of tabs over the years:
- Cloud name — on the Getting Started page of the dashboard. This is public; it appears in every image URL.
- Upload preset — under Settings → Upload. Create one and note its name. A preset is a saved bundle of upload rules (folder, allowed formats, max file size, incoming transformations) so the rules live in your Cloudinary config, not scattered through your code.
- API key and API secret — under Settings → Access Keys. The secret is the one that matters: it signs upload requests server-side and must never reach the browser.
In your Next.js project these become environment variables in .env.local:
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=your-cloud-name
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET=your-preset-name
CLOUDINARY_API_KEY=123456789012345
CLOUDINARY_API_SECRET=your-secret-here
The NEXT_PUBLIC_ prefix is Next.js's rule for values that are allowed in browser code. Notice what does not get the prefix: the API key and secret stay server-only. If you ever find yourself typing NEXT_PUBLIC_CLOUDINARY_API_SECRET, stop — that one keystroke would publish your signing secret to every visitor.
Two more pieces of setup. Install the packages:
npm install next-cloudinary cloudinary
next-cloudinary is the React component layer (the widget, the CldImage component); cloudinary is the Node SDK your server uses for signing. Then allow Cloudinary's domain in next.config.ts, because Next.js's Image component refuses to optimize images from hosts you haven't whitelisted:
const nextConfig = {
images: {
remotePatterns: [
{ protocol: "https", hostname: "res.cloudinary.com" },
],
},
};
How does a signed Cloudinary upload work in Next.js?
This is the part worth understanding rather than pasting, because it's the security model. Cloudinary supports two upload modes. Unsigned uploads let anyone with your cloud name and preset name upload files — fine for a prototype, dangerous in production, since both values are visible in your page source. Signed uploads require each upload request to carry a cryptographic signature generated with your API secret, which lives only on your server.
The flow has a nice division of labor — your server never touches the file itself, it only signs a permission slip:
sequenceDiagram
participant U as User's browser
participant W as CldUploadWidget
participant A as /api/sign-cloudinary-params
participant C as Cloudinary
U->>W: picks a file
W->>A: POST upload parameters
A->>A: sign params with API secret
A->>W: return signature
W->>C: upload file + signature (direct)
C->>C: verify signature, store, transform
C->>W: secure_url of stored image
W->>U: onSuccess fires with the URL
The file goes from the browser straight to Cloudinary — it never passes through your Next.js server, which means no serverless function timeouts on big files and no bandwidth bill on your host. Your server's only job is the signature, and that endpoint is about ten lines, straight from Cloudinary's own App Router guide:
// app/api/sign-cloudinary-params/route.ts
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
export async function POST(request: Request) {
const { paramsToSign } = await request.json();
const signature = cloudinary.utils.api_sign_request(
paramsToSign,
process.env.CLOUDINARY_API_SECRET!
);
return Response.json({ signature });
}
One honest caveat: as written, this endpoint signs requests for anyone who can reach it. In a real SaaS you should check the user's session at the top of that handler and reject unauthenticated calls — otherwise you've rebuilt unsigned uploads with extra steps. If your app has auth middleware (the Coding Capybaras boilerplate gates these routes by default), put this route behind it.
Wiring the upload widget and saving the result
With signing in place, the front end is one component. CldUploadWidget wraps Cloudinary's hosted upload UI — a polished modal that handles drag-and-drop, local files, camera capture, and remote URLs, so you write zero upload UI yourself:
// components/avatar-uploader.tsx
"use client";
import { CldUploadWidget } from "next-cloudinary";
export function AvatarUploader({ onUploadSuccess }: { onUploadSuccess: (url: string) => void }) {
return (
<CldUploadWidget
uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET}
signatureEndpoint="/api/sign-cloudinary-params"
onSuccess={(result) => {
if (typeof result.info === "object" && "secure_url" in result.info) {
onUploadSuccess(result.info.secure_url);
}
}}
options={{ singleUploadAutoClose: true }}
>
{({ open }) => (
<button type="button" onClick={() => open()}>
Upload image
</button>
)}
</CldUploadWidget>
);
}
The "use client" directive matters: the widget needs browser APIs, so it's a client component. But the page that uses it can stay a server component, and the handoff between the two is the elegant part of the App Router version of this pattern. You pass a server action as the onUploadSuccess prop:
// app/settings/page.tsx (server component)
import { revalidatePath } from "next/cache";
export default async function SettingsPage() {
const user = await getCurrentUser();
async function saveAvatar(url: string) {
"use server";
await updateUser({ avatar: url });
revalidatePath("/settings");
}
return <AvatarUploader onUploadSuccess={saveAvatar} />;
}
When the upload finishes, the widget calls saveAvatar with the new image's secure_url, the server action writes it to your database, and revalidatePath tells Next.js to re-render the page with fresh data — the new avatar appears without a page refresh and without any client-side state management. Store the URL (or better, the public_id) in your own database; Cloudinary stores the pixels, you store the pointer.
For displaying images, use CldImage from the same package in place of next/image — it accepts a public_id and builds optimized, transformed URLs (f_auto for format, q_auto for quality, any crop you want) automatically.
What does Cloudinary's free tier actually cover?
Pricing is where founders get nervous about Cloudinary, because "credits" sounds like a trap. It's actually simple. The free plan includes 25 monthly credits, and one credit equals any one of: 1,000 transformations, 1 GB of managed storage, or 1 GB of delivery bandwidth. The 25 credits can be spent in any mix — say 10 GB storage, 10 GB bandwidth, and 5,000 transformations.
Two details worth knowing before you build your mental model. Transformations and bandwidth are measured over a rolling 30-day window, not a calendar month that resets. And storage is a point-in-time total, so deleting assets frees credits immediately.
For context: an early-stage SaaS storing user avatars and a few marketing images will struggle to spend 5 of those 25 credits. Image-heavy products — user galleries, generated content, big files — are a different story, and that's when the comparison below starts to matter.
| | Cloudinary | UploadThing | AWS S3 (raw) | ImageKit | | --- | --- | --- | --- | --- | | What it is | Full media platform | File uploads for Next.js | Raw object storage | Media platform | | Transformations | URL-based, automatic | None built in | Build your own | URL-based, automatic | | Upload UI included | Yes (widget) | Yes (components) | No | Yes (widget) | | CDN delivery | Built in | Built in | Add CloudFront | Built in | | Setup effort for a non-dev | Low | Lowest | High | Low | | Free tier | 25 credits/mo | Free tier available | Pay-as-you-go | Free tier available |
The honest summary: if you want the absolute simplest "file in, URL out" for a Next.js app and don't need transformations, UploadThing is a lovely, simpler tool. If you'll ever need on-the-fly resizing, format conversion, and optimization — which describes most products with user-facing images — Cloudinary or ImageKit earn their complexity, and Cloudinary's Next.js tooling is the most mature of the bunch. Raw S3 is the right call mainly when you have unusual requirements or an engineer who enjoys this layer.
The Claude Code prompt that wires it all
Here's the prompt I'd paste into Claude Code or Cursor. It encodes the decisions from this post so the AI doesn't have to guess:
Add Cloudinary image uploads to this Next.js App Router project.
Requirements:
1. Install next-cloudinary and cloudinary.
2. Add res.cloudinary.com to images.remotePatterns in next.config.ts.
3. Create a signed-upload endpoint at app/api/sign-cloudinary-params/route.ts
using cloudinary.utils.api_sign_request and CLOUDINARY_API_SECRET.
Reject unauthenticated requests using our existing auth/session helper.
4. Create components/image-uploader.tsx: a client component wrapping
CldUploadWidget with uploadPreset from
NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET and
signatureEndpoint="/api/sign-cloudinary-params". Expose an
onUploadSuccess(url) prop.
5. Do NOT put the API key or secret in any client component or
NEXT_PUBLIC_ variable.
6. Tell me which env vars to add to .env.local, but do not create or
modify .env files yourself.
7. Use CldImage for display.
Use existing project conventions for file placement and styling.
Steps 5 and 6 are the load-bearing ones. Secrets handling is exactly the kind of thing AI assistants get almost right — and as I wrote in the 7 mistakes I made learning to code with AI, almost-right is the failure mode to design against. Constrain it explicitly and verify afterward: search your codebase for API_SECRET and confirm it appears only in the signing route.
After the AI finishes, run your checks the same way you would for any integration — typecheck, lint, then a real upload in the browser with the network tab open, the same verification rhythm from the Sentry and PostHog walkthroughs. You should see one POST to your signing route, then one direct upload to api.cloudinary.com.
Frequently asked questions
Do I need signed uploads, or is an unsigned preset fine?
For anything public-facing, use signed uploads. Unsigned presets expose your cloud name and preset in the page source, and anyone who copies them can upload files into your account — burning your credits and potentially hosting content you really don't want associated with your business. Unsigned is acceptable for a local prototype, nothing more.
Should I store images in Cloudinary or in Supabase Storage?
Both work, and if you're on the Supabase stack it's a fair question. Supabase Storage gives you files in buckets next to your database, but no transformation pipeline — resizing and optimizing is on you. Cloudinary's entire value is that pipeline. My rule: config-style files (PDF exports, CSVs) go in Supabase Storage; anything rendered as an image to users goes through Cloudinary.
What happens when I outgrow the free tier?
You'll see usage in the Cloudinary dashboard well before it's a problem — watch the credits gauge. Paid plans start with more credits plus overage billing. The nice property is that the migration cost is zero: it's the same account and the same URLs, just a bigger allowance.
Does the upload widget work with the Next.js App Router and server components?
Yes — the widget itself must be a client component ("use client"), but it composes cleanly with server components: render it from a server page and pass a server action to its success callback, as shown above. That's the officially recommended pattern in Cloudinary's App Router guide.
Can the AI assistant do this whole integration without me?
It can write every file, and with the prompt above it usually gets the structure right on the first pass. What it can't do is create your Cloudinary account, generate the upload preset, or paste secrets into .env.local — and you shouldn't want it to. Dashboard clicks and secret handling staying human is a feature, not a limitation.
Conclusion
A Cloudinary Next.js upload integration comes down to four pieces: env vars, one signing route, one widget component, and a server action to save the URL. None of it is hard once you know the shape — and now you know the shape, which means you can verify what your AI assistant builds instead of hoping. That's the whole game for non-technical founders: understand the architecture, delegate the typing.
The Cloudinary integration guide on Coding Capybaras has this prompt ready to copy, alongside guides for the rest of a production SaaS stack — paste it into Claude Code and you'll have working image uploads before your coffee goes cold.