Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Image uploads in Next.js App Router with Cloudinary

May 30, 2026
nextjscloudinarysaasdrizzle-orm

Image uploads in Next.js App Router with Cloudinary

Every SaaS eventually needs file uploads -- profile pictures, cover images, attachments. Storing files locally or as base64 in the database is the wrong move. Cloudinary handles storage, CDN, and on-the-fly transformations so you don't have to.

This post walks through the complete pattern used in this boilerplate: a protected upload route, a reusable hook, and storing only the public_id in the database.

The upload route

The route lives at app/api/upload/route.ts. It accepts a FormData body with a file field, uploads to Cloudinary, and returns the public URL. Only authenticated users can call it.

// app/api/upload/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { v2 as cloudinary } from 'cloudinary';
import { getUserFromRequest } from '@/lib/auth';
import { handleError } from '@/lib/errors';
 
cloudinary.config({
  cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
  api_key: process.env.CLOUDINARY_API_KEY,
  api_secret: process.env.CLOUDINARY_API_SECRET,
});
 
export async function POST(req: NextRequest) {
  try {
    const user = await getUserFromRequest(req);
    const formData = await req.formData();
    const file = formData.get('file');
 
    if (!(file instanceof Blob)) {
      return NextResponse.json({ error: 'No file provided' }, { status: 400 });
    }
 
    const arrayBuffer = await file.arrayBuffer();
    const buffer = Buffer.from(arrayBuffer);
    const dataUri = `data:${file.type};base64,${buffer.toString('base64')}`;
 
    const result = await cloudinary.uploader.upload(dataUri, {
      folder: `users/${user.id}`,
    });
 
    return NextResponse.json({ url: result.secure_url, publicId: result.public_id });
  } catch (error) {
    return handleError(error);
  }
}

getUserFromRequest throws a 401 if the Bearer token is missing or invalid -- no extra guard needed. Files land in a per-user folder, so listing or deleting a user's assets later is straightforward.

The upload hook

Encapsulate the fetch call so components don't repeat it:

// hooks/use-image-upload.ts
'use client';
 
import { useState } from 'react';
 
interface UploadResult {
  url: string;
  publicId: string;
}
 
export function useImageUpload() {
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState<string | null>(null);
 
  async function upload(file: File, token: string): Promise<UploadResult | null> {
    setUploading(true);
    setError(null);
    try {
      const formData = new FormData();
      formData.append('file', file);
 
      const res = await fetch('/api/upload', {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}` },
        body: formData,
      });
 
      if (!res.ok) {
        const body = await res.json();
        throw new Error(body.error ?? 'Upload failed');
      }
 
      return await res.json();
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Upload failed');
      return null;
    } finally {
      setUploading(false);
    }
  }
 
  return { upload, uploading, error };
}

One detail: do not set Content-Type manually when posting FormData. The browser needs to set the multipart/form-data boundary itself -- overriding it breaks the parse on the server.

Store the public_id, not the URL

The Cloudinary upload response has both secure_url and public_id. Store the public_id in the database. The URL is derived at render time, so you can swap dimensions, formats, or quality without touching the DB.

Add a column to your Drizzle schema:

// modules/user/user.schema.ts
export const userTable = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: text('email').notNull().unique(),
  passwordHash: text('password_hash').notNull(),
  avatarPublicId: text('avatar_public_id'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
});

Then update it from the service layer after a successful upload:

// modules/user/user.service.ts
async function updateAvatar(userId: string, avatarPublicId: string) {
  await db
    .update(userTable)
    .set({ avatarPublicId })
    .where(eq(userTable.id, userId));
}

Generate and apply the migration with npm run db:generate && npm run db:migrate.

Render with transformations

Build the URL at render time using Cloudinary's transformation string:

// lib/cloudinary.ts
export function getImageUrl(publicId: string, width = 400, height = 400) {
  const cloud = process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME;
  return `https://res.cloudinary.com/${cloud}/image/upload/w_${width},h_${height},c_fill,f_auto,q_auto/${publicId}`;
}

f_auto lets Cloudinary pick WebP or AVIF based on the browser. q_auto tunes compression without you picking a number. Change width or height per callsite -- 80px for a nav avatar, 400px for a profile page, no re-upload needed.

A minimal upload component

// components/avatar-upload.tsx
'use client';
 
import { useImageUpload } from '@/hooks/use-image-upload';
import { getImageUrl } from '@/lib/cloudinary';
 
interface Props {
  token: string;
  publicId?: string;
  onUpload: (publicId: string) => void;
}
 
export function AvatarUpload({ token, publicId, onUpload }: Props) {
  const { upload, uploading, error } = useImageUpload();
 
  async function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;
    const result = await upload(file, token);
    if (result) onUpload(result.publicId);
  }
 
  return (
    <div className="flex flex-col gap-2">
      {publicId && (
        <img
          src={getImageUrl(publicId, 80, 80)}
          alt="Avatar"
          width={80}
          height={80}
          className="rounded-full"
        />
      )}
      <input type="file" accept="image/*" onChange={handleChange} disabled={uploading} />
      {uploading && <p className="text-sm text-muted-foreground">Uploading...</p>}
      {error && <p className="text-sm text-destructive">{error}</p>}
    </div>
  );
}

Next steps

Add a file-type guard (['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) and a size cap (file.size > 5 * 1024 * 1024) before calling upload. Both checks run client-side and cut unnecessary round trips.

The same route and hook cover any upload type -- just change the folder passed to cloudinary.uploader.upload and the transformation string in getImageUrl. The pattern is the same whether you are uploading avatars, cover images, or PDF attachments.