Skip to main content

Guide

Cloudinary vs Uploadthing vs ImageKit 2026

Compare Cloudinary, Uploadthing, and ImageKit for image storage, optimization, and CDN delivery. Pricing, developer experience, transformations, and which to.

·PkgPulse Team·
0

TL;DR

For image handling in 2026: Uploadthing is the best choice for Next.js and TypeScript projects — type-safe, serverless-first, and designed for modern app stacks with minimal configuration. Cloudinary is the enterprise choice with the most comprehensive transformation API and CDN. ImageKit is the best value — real-time image optimization at a fraction of Cloudinary's price, with a free tier that scales reasonably.

Key Takeaways

  • Cloudinary: The most feature-complete — 500+ image/video transformations, AI-powered features
  • Uploadthing: Best DX for Next.js — type-safe upload routes, built-in auth, minimal setup
  • ImageKit: Best value — similar CDN performance to Cloudinary at 10-50% of the cost
  • All three serve images via CDN with automatic format detection (WebP/AVIF)
  • Uploadthing wins for developer simplicity; Cloudinary wins for transformation power
  • For most startups: ImageKit free tier (20GB) + Next.js Image component is sufficient

PackageWeekly DownloadsCategory
cloudinary~530KNode.js SDK
next-cloudinary~120KNext.js component
uploadthing~220KType-safe uploads
imagekit~80KNode.js SDK

The Image Optimization Pipeline

All three services handle this pipeline on their infrastructure:

User uploads image
       ↓
Upload to CDN storage (S3-backed)
       ↓
On-demand transformation (resize, crop, format convert, optimize)
       ↓
Serve from edge CDN (closest server to user)
       ↓
Cache transformed image for future requests

The differences are in: developer API, transformation capabilities, pricing, and ecosystem integrations.


Cloudinary

Cloudinary is the industry standard for media management:

import { v2 as cloudinary } from "cloudinary"
import { UploadApiResponse } from "cloudinary"

cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
  secure: true,
})

// Upload a file:
async function uploadPackageScreenshot(filePath: string, packageName: string): Promise<string> {
  const result: UploadApiResponse = await cloudinary.uploader.upload(filePath, {
    folder: `packages/${packageName}`,
    public_id: "screenshot",
    overwrite: true,
    resource_type: "image",
    transformation: [
      { width: 1200, height: 630, crop: "fill", gravity: "center" },
      { quality: "auto", fetch_format: "auto" },  // Auto WebP/AVIF
    ],
    tags: ["package-screenshot", packageName],
  })

  return result.secure_url
}

// Generate transformation URLs (without re-uploading):
function getOptimizedUrl(publicId: string, width: number, height?: number): string {
  return cloudinary.url(publicId, {
    transformation: [
      { width, height, crop: height ? "fill" : "scale" },
      { quality: "auto:best", fetch_format: "auto" },
      { dpr: "auto" },  // Serve 2x for retina
    ],
    secure: true,
  })
}

// Advanced transformations:
const overlayUrl = cloudinary.url("package-card", {
  transformation: [
    { width: 1200, height: 630 },
    // Overlay text:
    {
      overlay: { font_family: "Inter", font_size: 48, font_weight: "bold", text: "react" },
      color: "#ffffff",
      gravity: "north_west",
      x: 60,
      y: 60,
    },
    // Overlay an icon:
    {
      overlay: "npm-logo",
      width: 80,
      gravity: "south_east",
      x: 30,
      y: 30,
    },
    { quality: "auto", fetch_format: "auto" },
  ],
})

Cloudinary's transformation API power:

URL-based transformations — no upload required for new sizes:
https://res.cloudinary.com/demo/image/upload/
  w_400,h_300,c_fill,g_face/     ← Resize to 400×300, crop to face
  e_grayscale/                    ← Apply grayscale
  e_blur:200/                     ← Apply blur
  q_auto,f_auto/                  ← Auto quality and format
  sample.jpg

next-cloudinary (Next.js component):

import { CldImage, CldUploadButton } from "next-cloudinary"

// Drop-in replacement for next/image with Cloudinary optimization:
function PackageCard({ imagePublicId }: { imagePublicId: string }) {
  return (
    <CldImage
      src={imagePublicId}
      width={800}
      height={450}
      alt="Package screenshot"
      crop="fill"
      gravity="center"
      sizes="(max-width: 768px) 100vw, 800px"
    />
  )
}

// Upload widget (no backend needed):
function UploadButton({ onUpload }: { onUpload: (url: string) => void }) {
  return (
    <CldUploadButton
      uploadPreset="pkgpulse-screenshots"
      onSuccess={(result) => {
        if (result.info && typeof result.info === "object") {
          onUpload(result.info.secure_url)
        }
      }}
    >
      Upload Screenshot
    </CldUploadButton>
  )
}

Cloudinary pricing: Free tier = 25 credits/month (~25GB storage + 25GB bandwidth). Paid from $89/month. Expensive at scale.


Uploadthing

Uploadthing was built specifically for Next.js and modern TypeScript stacks:

// app/api/uploadthing/core.ts — Define upload routes:
import { createUploadthing, type FileRouter } from "uploadthing/next"
import { getServerSession } from "next-auth"

const f = createUploadthing()

export const ourFileRouter = {
  // Package screenshot upload (authenticated):
  packageScreenshot: f({
    image: {
      maxFileSize: "4MB",
      maxFileCount: 1,
    },
  })
    .middleware(async ({ req }) => {
      const session = await getServerSession()
      if (!session?.user) throw new Error("Unauthorized")

      // Return metadata that becomes available in onUploadComplete:
      return { userId: session.user.id }
    })
    .onUploadComplete(async ({ metadata, file }) => {
      // Save to database:
      await db.packageImage.create({
        data: {
          url: file.url,
          key: file.key,
          uploadedBy: metadata.userId,
        },
      })

      // Return data to the client:
      return { uploadedBy: metadata.userId, url: file.url }
    }),

  // Bulk imports (multiple files, higher limits):
  csvImport: f({ text: { maxFileSize: "16MB", maxFileCount: 1 } })
    .middleware(async ({ req }) => {
      const session = await getServerSession()
      if (!session?.user?.isAdmin) throw new Error("Admin only")
      return { userId: session.user.id }
    })
    .onUploadComplete(async ({ file }) => {
      await processCsvImport(file.url)
      return { processed: true }
    }),
} satisfies FileRouter

export type OurFileRouter = typeof ourFileRouter
// app/api/uploadthing/route.ts:
import { createRouteHandler } from "uploadthing/next"
import { ourFileRouter } from "./core"

export const { GET, POST } = createRouteHandler({ router: ourFileRouter })
// Client component — type-safe upload:
"use client"
import { useUploadThing } from "~/utils/uploadthing"

function PackageScreenshotUploader() {
  const { startUpload, isUploading } = useUploadThing("packageScreenshot", {
    onClientUploadComplete: (res) => {
      console.log("Uploaded:", res?.[0].url)
      // res is fully typed based on your router definition
    },
    onUploadError: (error) => {
      console.error("Upload error:", error.message)
    },
  })

  const handleDrop = (files: File[]) => {
    startUpload(files)
  }

  return (
    <div onDrop={(e) => handleDrop([...e.dataTransfer.files])}>
      {isUploading ? "Uploading..." : "Drop image here"}
    </div>
  )
}

Uploadthing's design philosophy:

  • File router definitions are just TypeScript — no config files, no UI
  • Middleware has access to your session/auth context directly
  • Client hooks are generated from your router type — fully type-safe
  • Files are stored on Uploadthing's S3 infrastructure with CDN delivery
  • No CDN image transformation — use next/image for optimization

Uploadthing pricing: Free tier = 2GB storage. Paid from $10/month. Very affordable for small apps.


ImageKit

ImageKit is the Cloudinary alternative with better pricing:

import ImageKit from "imagekit"
import { UploadResponse } from "imagekit/dist/libs/interfaces"

const imagekit = new ImageKit({
  publicKey: process.env.IMAGEKIT_PUBLIC_KEY!,
  privateKey: process.env.IMAGEKIT_PRIVATE_KEY!,
  urlEndpoint: process.env.IMAGEKIT_URL_ENDPOINT!,
})

// Upload:
async function uploadImage(buffer: Buffer, fileName: string): Promise<string> {
  const result: UploadResponse = await imagekit.upload({
    file: buffer,
    fileName,
    folder: "/packages",
    tags: ["package-image"],
    useUniqueFileName: true,
    transformation: {
      pre: "w-1200,h-630,cm-pad_resize",
    },
  })

  return result.url
}

// URL transformation (same as Cloudinary's URL approach):
function getResizedUrl(imageUrl: string, width: number, quality = 80): string {
  // Append transformation parameters to URL:
  return `${imageUrl}?tr=w-${width},q-${quality},f-auto`
}

// Or use ImageKit's URL builder:
function buildTransformedUrl(imageId: string, transforms: object): string {
  return imagekit.url({
    path: imageId,
    transformation: [transforms],
  })
}

ImageKit URL transformation syntax:

https://ik.imagekit.io/demo/image.jpg
  ?tr=w-400,h-300,c-maintain_ratio    ← Resize maintaining ratio
  :w-400,h-300,cm-extract,x-100,y-50  ← Chained transformations

Built-in transformations:
- w (width), h (height)
- c (crop: maintain_ratio, force, at_least, at_max)
- cm (crop mode: extract, pad_resize, pad_extract)
- f (format: auto, webp, avif, jpg, png)
- q (quality: 0-100 or auto)
- bl (blur), rt (rotate), flip-h, flip-v
- e-sharpen, e-grayscale, e-contrast
- lo-true (lossless)

Next.js Image with ImageKit:

// next.config.ts:
export default {
  images: {
    remotePatterns: [
      {
        protocol: "https",
        hostname: "ik.imagekit.io",
      },
    ],
  },
}

// Component — just use next/image normally:
import Image from "next/image"

function PackageImage({ src }: { src: string }) {
  return (
    <Image
      src={src}
      width={800}
      height={450}
      alt="Package screenshot"
      // next/image handles srcset, WebP conversion, and lazy loading
      // ImageKit handles CDN delivery from the URL
    />
  )
}

ImageKit pricing: Free tier = 20GB bandwidth/month + 20GB storage. Paid from $49/month (vs Cloudinary's $89/month). Roughly 40-50% cheaper than Cloudinary for equivalent features.


Feature Comparison

FeatureCloudinaryUploadthingImageKit
Image transformations⭐⭐⭐⭐⭐ 500+❌ (use next/image)⭐⭐⭐⭐ 100+
Video transformations
TypeScript DX⚠️ Average✅ Excellent✅ Good
Next.js integration✅ next-cloudinary✅ Native✅ Via remotePatterns
Type-safe file router
AI background removal
CDN delivery
Free tier25 credits/mo2GB storage20GB bandwidth
Pricing$$$$$$
Upload widget
Signed uploads✅ (middleware)

When to Use Each

Choose Cloudinary if:

  • You need 500+ transformations (generative AI, gravity, overlays)
  • Video transformation is required
  • Enterprise team with Cloudinary expertise already
  • Budget is not a constraint

Choose Uploadthing if:

  • Building with Next.js App Router and TypeScript
  • Simplest possible setup is the priority
  • Type-safe upload routes with auth middleware appeal to you
  • You'll use next/image for optimization (Uploadthing is storage, not CDN transformer)

Choose ImageKit if:

  • You want Cloudinary-level CDN and transformations at lower cost
  • 20GB free tier is sufficient for your stage
  • Migration from Cloudinary is being considered

Security: Preventing Unauthorized Uploads and Storage Abuse

File upload endpoints are a common attack vector. Without proper validation and authorization, a misconfigured upload endpoint allows arbitrary file storage, potential malware distribution through your CDN, and storage cost attacks where an adversary uploads files to exhaust your storage quota.

Uploadthing's file router is the most opinionated about security by design. The middleware function runs on your server before any upload is accepted — if it throws, the upload is rejected with a 401. You explicitly declare what file types and sizes are allowed per route: f({ image: { maxFileSize: "4MB", maxFileCount: 1 } }). This means the constraints are enforced at the SDK level, not just client-side validation. The route-based design also means each upload use case has its own authentication check, reducing the surface area for privilege escalation — your packageScreenshot route requires a logged-in user, while your adminImport route additionally requires admin role.

Cloudinary's unsigned upload presets are convenient but require careful configuration. An unsigned upload preset that allows any file format and no size limit is a public endpoint anyone can find and abuse. Production Cloudinary configurations should use signed uploads: your backend generates a signature using your api_secret, the client receives the signature along with upload parameters, and Cloudinary validates the signature before accepting the file. The signature includes the folder, public_id format, and transformation constraints, preventing users from overriding them.

ImageKit's authentication token approach follows the same pattern as Cloudinary's signed uploads. Your server generates a signature covering the token, expire time, and upload parameters, and the client sends these alongside the file. The token expires after a short window (typically 60 seconds), preventing signature replay attacks. For both Cloudinary and ImageKit, the key operational concern is that your api_secret (or ImageKit privateKey) must never be exposed in client-side code — the signing step must always happen on the server.

Image Transformation Performance and CDN Delivery Download data from npm registry (weekly average, February 2026). Feature comparison based on Cloudinary Node.js SDK v2, Uploadthing v7, and ImageKit Node.js SDK v5.

Image Transformation Performance and CDN Delivery

All three services perform image transformations on-demand and cache results at the CDN edge. The practical difference is in how transformations are specified and how aggressively results are cached. Cloudinary's URL-based transformation pipeline is the most powerful — chaining multiple operations in a single URL request is a first-class design feature, and the transformation results are cached indefinitely at Cloudinary's global CDN. A URL like /c_fill,g_face/e_grayscale/q_auto,f_auto/photo.jpg runs face detection, crops to the face region, converts to grayscale, and auto-optimizes format — all as a single request.

ImageKit's URL parameter syntax is similar but uses a different notation (?tr=w-400,h-300,q-80 versus Cloudinary's /w_400,h_300,q_80). The practical transformation set — resize, crop, format conversion, quality optimization, blur, sharpen — overlaps significantly with Cloudinary for 90% of use cases. Where Cloudinary pulls ahead is generative AI features: background removal with AI, generative fill, and object-aware cropping. ImageKit added AI background removal in 2025, but Cloudinary's AI transformation suite is broader and more production-tested.

Uploadthing sits in a different category — it's a file storage and delivery service, not a transformation CDN. Files are stored on S3-compatible infrastructure and delivered via CDN, but URL-based transformation isn't part of the service design. The recommended pattern is to pair Uploadthing storage with Next.js's built-in next/image component, which handles client-side optimization (responsive srcset, lazy loading, WebP conversion via the Next.js image optimization service). This works well for Next.js applications but requires an additional image optimization service for other frameworks.

Handling Uploads in Server Actions (Next.js App Router)

The server action model in Next.js App Router changes how file uploads integrate with each service. Cloudinary and ImageKit both support server-side upload via signed upload tokens — your server generates a signed URL, returns it to the client, and the client uploads directly to the CDN without the file passing through your server. This pattern is efficient for large files but requires a server round-trip for the signature.

Uploadthing's file router is designed specifically for this pattern in a type-safe way. The middleware callback on the server generates auth context, the onUploadComplete callback saves the URL to your database, and the client hook handles the upload flow with progress tracking. The TypeScript inference from the server-defined router to the client hook eliminates the mismatch between what your server expects and what the client sends:

// Type safety flows from server router definition to client hook
// If you change maxFileSize on the server, TypeScript will error
// on client code that violates the new constraint at compile time

For teams using Drizzle or Prisma, Uploadthing's onUploadComplete callback is where you save the uploaded file's URL and key to your database — the pattern integrates naturally with server-side ORM calls that would be awkward inside a Cloudinary or ImageKit client-side callback.

Compare image SDK packages on PkgPulse →

See also: Next.js vs Remix and Next.js vs Nuxt.js, better-auth vs Lucia vs NextAuth 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.