Uploading large files in web applications can be challenging, especially when dealing with limitations like body size limits. In this post, we’ll explore how to efficiently handle large file uploads in a Next.js application using chunking and Server Actions. We’ll break the file into smaller chunks, upload them one by one, merge them on the server, and delete the temporary chunks after the merge is complete.

Introduction

When working with file uploads, many frameworks (including Next.js) impose a body size limit on requests. In Next.js 14, for instance, the default body size limit for requests in Server Actions is 1MB. This can be a problem when uploading large files, so a common solution is to split the file into smaller chunks and upload them individually.

Step 1: Chunking the File on the Client Side

We begin by splitting the file into smaller chunks (1MB each in this case) on the client side and uploading each chunk using the uploadImage Server Action.

<!-- Frontend: Chunking and Uploading -->
<script type="text/jsx">
  "use client";

  import { uploadImage } from '@/action/upload-image';
  import React, { ChangeEvent, FormEvent, useState } from 'react';
  import { useFormState } from 'react-dom';

  const Home = () => {
    const [state, action] = useFormState(uploadImage, {
      progress: 0,
    });
    const [file, setFile] = useState<File>();
    const [isUploading, setIsUploading] = useState(false);

    // Handles the file selection event
    const handleFileChange = (e: ChangeEvent<HTMLInputElement>) => {
      const files = e.target.files;
      if (files?.length) {
        setFile(files[0]);
      }
    };

    // Uploads each chunk to the server using the Server Action
    const uploadChunk = async (
      file: File,
      start: number,
      end: number,
      chunkNumber: number,
      totalChunks: number,
    ) => {
      const formData = new FormData();
      formData.append('file', file.slice(start, end)); // Slice the file into chunks
      formData.append('totalChunks', totalChunks.toString()); // Total number of chunks
      formData.append('chunkNumber', chunkNumber.toString()); // Current chunk number
      formData.append('fileName', file.name); // File name for the merged file
      formData.append('fileType', file.type); // File type for proper handling on the server

      // Call the server action with the form data
      action(formData);
    };

    // Handles the submit event and starts the chunk upload process
    const handleSubmit = async (e: FormEvent<HTMLButtonElement>) => {
      e.preventDefault();

      if (!file) return; // Check if file is selected

      setIsUploading(true);

      const chunkSize = 1024 * 1024; // 1MB per chunk
      const totalChunks = Math.ceil(file.size / chunkSize);

      try {
        // Loop through all chunks and upload each one
        for (let i = 0; i < totalChunks; i++) {
          const start = i * chunkSize;
          const end = Math.min((i + 1) * chunkSize, file.size);

          await uploadChunk(file, start, end, i + 1, totalChunks);
        }
      } catch (error) {
        console.error("Error during upload:", error);
      } finally {
        setIsUploading(false); // Set uploading to false when done
      }
    };

    return (
      <div>
        <input type="file" name="file" onChange={handleFileChange} />
        <button onClick={handleSubmit}>Upload {state.progress}%</button>
      </div>
    );
  };

  export default Home;
</script>

Step 2: Handling File Uploads with Server Actions

On the server side, we use the uploadImage Server Action to handle each chunk. When all chunks have been uploaded, the server merges them into a single file and deletes the temporary chunk files.

<!-- Backend: Handling Chunks on the Server -->
<script type="text/typescript">
  'use server';
  import fs from 'fs';
  import path from 'path';

  const UPLOAD_DIR = path.join(process.cwd(), 'uploads');

  // Ensure the uploads directory exists
  if (!fs.existsSync(UPLOAD_DIR)) {
    fs.mkdirSync(UPLOAD_DIR);
  }

  // Server Action to handle the file chunk upload
  export const uploadImage = async (_: any, formData: FormData) => {
    const file = formData.get('file') as Blob;
    const totalChunks = formData.get('totalChunks') as string;
    const chunkNumber = formData.get('chunkNumber') as string;
    const fileName = formData.get('fileName') as string;

    const chunkPath = path.join(UPLOAD_DIR, 'chunk-${chunkNumber}');
    const buffer = Buffer.from(await file.arrayBuffer());

    // Write the chunk to the server
    fs.writeFileSync(chunkPath, buffer);

    // Check if the last chunk is uploaded and merge the file
    if (totalChunks === chunkNumber) {
      const mergedFilePath = path.join(UPLOAD_DIR, '${fileName}');
      const writeStream = fs.createWriteStream(mergedFilePath);

      // Merge the chunks
      for (let i = 1; i <= parseInt(totalChunks); i++) {
        const chunk = fs.readFileSync(path.join(UPLOAD_DIR, 'chunk-${i}'));
        writeStream.write(chunk);
      }

      writeStream.end();

      // After merging, remove the chunk files
      for (let i = 1; i <= parseInt(totalChunks); i++) {
        const chunkFilePath = path.join(UPLOAD_DIR, 'chunk-${i}');
        fs.unlinkSync(chunkFilePath); // Synchronously delete the chunk file
      }
    }

    // Return the upload progress
    return {
      progress: Math.min(((+chunkNumber + 1) / +totalChunks) * 100, 100),
    };
  };
</script>

Step 3: Cleanup After Merging the File

Once the chunks are merged, we need to clean up the temporary chunk files. This is done by calling fs.unlinkSync() to delete each chunk after it has been written to the final file.

In the uploadImage server action, after merging all chunks, we loop through all the chunk files and delete them:

for (let i = 1; i <= parseInt(totalChunks); i++) {
  const chunkFilePath = path.join(UPLOAD_DIR, 'chunk-${i}');
  fs.unlinkSync(chunkFilePath); // Synchronously delete the chunk file
}

Conclusion

By chunking large files into smaller pieces, we can bypass the body size limits imposed by Next.js and other frameworks. The Server Action makes it easier to handle file uploads on the server side, and the cleanup process ensures that temporary chunk files are deleted after use.

This approach is perfect for handling large file uploads in Next.js apps, and it can be adapted to work with different file types, chunk sizes, and other upload requirements.

Happy coding!

Leave a Reply

Your email address will not be published. Required fields are marked *