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 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.
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.
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.
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.
// 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>
);
}
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.