DEV Community

Anas Sheikh
Anas Sheikh

Posted on

File and Image Uploads in Next.js 15 The Pattern I Actually Use

Uploading a file sounds simple until you actually build it. Validate the file type, check the size before it eats your server's memory, get it to storage without routing gigabytes through your own server, and show progress while it happens. Here is the setup I use now.


1. Why Files Should Not Go Through Your Server Directly

The naive approach sends the file to a Server Action, which then uploads it to storage from there. This works for small files, but it means every upload consumes your server's memory and bandwidth twice, once receiving it from the user, once sending it to storage.

The pattern that actually scales is generating a signed upload URL on the server, then having the browser upload directly to storage (S3, Cloudinary, UploadThing) using that URL. Your server never touches the file bytes at all.


2. Getting a Signed Upload URL

Using Cloudinary as the example, though the same shape applies to S3 or any similar service:

// actions/upload.ts
'use server';
import { v2 as cloudinary } from 'cloudinary';
import { getSession } from '@/lib/auth';

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 getUploadSignature() {
  const session = await getSession();
  if (!session) throw new Error('Not authenticated');

  const timestamp = Math.round(Date.now() / 1000);

  const signature = cloudinary.utils.api_sign_request(
    { timestamp, folder: `uploads/${session.userId}` },
    process.env.CLOUDINARY_API_SECRET as string
  );

  return {
    signature,
    timestamp,
    cloudName: process.env.CLOUDINARY_CLOUD_NAME,
    apiKey: process.env.CLOUDINARY_API_KEY,
    folder: `uploads/${session.userId}`,
  };
}
Enter fullscreen mode Exit fullscreen mode

Scoping the folder to session.userId here matters. It keeps each user's uploads isolated, and it means the signature this Server Action generates can only be used to upload into that specific user's folder, not anywhere else in your storage.


3. Uploading Directly from the Client

// components/ImageUploader.tsx
'use client';
import { useState } from 'react';
import { getUploadSignature } from '@/actions/upload';

export function ImageUploader({ onUploadComplete }: { onUploadComplete: (url: string) => void }) {
  const [uploading, setUploading] = useState(false);
  const [error, setError] = useState('');

  async function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
    const file = e.target.files?.[0];
    if (!file) return;

    if (!file.type.startsWith('image/')) {
      setError('Only image files are allowed');
      return;
    }

    if (file.size > 5 * 1024 * 1024) {
      setError('File must be under 5MB');
      return;
    }

    setUploading(true);
    setError('');

    try {
      const { signature, timestamp, cloudName, apiKey, folder } = await getUploadSignature();

      const formData = new FormData();
      formData.append('file', file);
      formData.append('signature', signature);
      formData.append('timestamp', String(timestamp));
      formData.append('api_key', apiKey as string);
      formData.append('folder', folder);

      const res = await fetch(`https://api.cloudinary.com/v1_1/${cloudName}/image/upload`, {
        method: 'POST',
        body: formData,
      });

      const data = await res.json();
      onUploadComplete(data.secure_url);
    } catch {
      setError('Upload failed');
    } finally {
      setUploading(false);
    }
  }

  return (
    <div>
      <input type="file" accept="image/*" onChange={handleFileChange} disabled={uploading} />
      {uploading && <p>Uploading...</p>}
      {error && <p className="text-red-400">{error}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The file goes straight from the browser to Cloudinary. The Server Action's only job was generating a short-lived signature, it never receives the actual file at all.


4. Validating Before the Upload, Not After

Client-side validation (file type, size) is for user experience, catching an obvious mistake before wasting time on an upload that will fail anyway. It is not the real security boundary, since anyone can bypass client code entirely.

The real validation happens on the storage provider's side, configured through upload presets or signed parameters, and again wherever the resulting URL gets saved to your database:

// actions/saveAvatar.ts
'use server';
import { z } from 'zod';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { getSession } from '@/lib/auth';

const UrlSchema = z.string().url().refine(
  (url) => url.includes('res.cloudinary.com'),
  { message: 'Invalid upload source' }
);

export async function saveAvatarUrl(url: string) {
  const session = await getSession();
  if (!session) throw new Error('Not authenticated');

  const parsed = UrlSchema.safeParse(url);
  if (!parsed.success) {
    return { success: false, message: 'Invalid image URL' };
  }

  await connectDB();
  await User.findByIdAndUpdate(session.userId, { avatar: parsed.data });

  return { success: true };
}
Enter fullscreen mode Exit fullscreen mode

Checking that the URL actually belongs to your Cloudinary account, not just that it looks like a URL, closes off someone submitting an arbitrary image URL from anywhere on the internet and having your app treat it as a verified upload.


5. Handling Multiple Files

// components/GalleryUploader.tsx
'use client';
import { useState } from 'react';

export function GalleryUploader({ onComplete }: { onComplete: (urls: string[]) => void }) {
  const [progress, setProgress] = useState({ done: 0, total: 0 });

  async function handleFiles(e: React.ChangeEvent<HTMLInputElement>) {
    const files = Array.from(e.target.files ?? []);
    setProgress({ done: 0, total: files.length });

    const urls: string[] = [];

    for (const file of files) {
      const url = await uploadSingleFile(file); // reuses the single-upload logic
      urls.push(url);
      setProgress((prev) => ({ ...prev, done: prev.done + 1 }));
    }

    onComplete(urls);
  }

  return (
    <div>
      <input type="file" accept="image/*" multiple onChange={handleFiles} />
      {progress.total > 0 && (
        <p>{progress.done} / {progress.total} uploaded</p>
      )}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Uploading sequentially in a loop, rather than firing all uploads with Promise.all, keeps memory and bandwidth usage predictable when someone selects twenty files at once. Parallel uploads are faster, but sequential is safer as a default until you have a reason to optimize for speed specifically.


6. Deleting Files When Records Are Deleted

An easy thing to forget, if a user deletes their avatar or a post with an attached image, the file itself stays in storage unless you explicitly remove it.

// actions/deleteAvatar.ts
'use server';
import { v2 as cloudinary } from 'cloudinary';
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { getSession } from '@/lib/auth';

export async function deleteAvatar() {
  const session = await getSession();
  if (!session) throw new Error('Not authenticated');

  await connectDB();
  const user = await User.findById(session.userId);

  if (user?.avatar) {
    const publicId = extractPublicId(user.avatar);
    await cloudinary.uploader.destroy(publicId);
  }

  await User.findByIdAndUpdate(session.userId, { avatar: null });
}

function extractPublicId(url: string): string {
  const parts = url.split('/');
  const filename = parts[parts.length - 1];
  return filename.split('.')[0];
}
Enter fullscreen mode Exit fullscreen mode

Without this, storage usage grows indefinitely with orphaned files nobody can see or reach anymore, quietly increasing your storage bill over time.


Summary

Pattern Handles
Signed upload URL from a Server Action Direct browser-to-storage upload, no file through your server
Folder scoped to userId Isolating and restricting what each signature can upload
Client-side type and size checks Fast feedback, not the real security boundary
URL validation on save Confirming the uploaded file actually came from your storage account
Sequential upload loop Predictable memory and bandwidth for multiple files
Cleanup on delete Preventing orphaned files from growing your storage bill

The core shift from a naive implementation: the file itself should never pass through your Next.js server at all. Your server's only job is issuing a short-lived, scoped permission to upload, and validating the result afterward.

I use this exact direct-upload pattern for avatars, galleries, and document uploads across the dashboards and templates I build.

Get the templates: https://pixelanas.gumroad.com

Do you handle uploads through your own server, or go straight to storage like this? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)