This article may contain affiliate links. If you buy some products using those links, I may receive monetary benefits. See affiliate disclosure here

If I have to upload files to DigitalOcean Spaces from a Next.js app, here is how I would do it. I'll try to explain the steps as short as possible, without over-complicating.

As you might already know, DigitalOcean Spaces is an S3-compatible object storage, which implies you use the same SDKs and methods that you use to upload to an AWS S3 bucket. It's mostly a drop-in-replacement.

Overview

Here is a high-level overview of the things we're doing:

  1. Create a Spaces bucket and generate the API keys - access key, and secret key.
  2. Store the keys in an .env file in at the root of your Next.js application.
  3. On one side, we have a client component (React/Next.js), like UploadForm.tsx, which output a form element that allows the user to select and submit a file input.
  4. Once the file input is received, check the mimeType to ensure that it is either JPEG or PNG. Otherwise, throw an error.
  5. If it is an image file, send an API request to /api/presigned-url along with the file data (key, and mimeType).
  6. The presigned URL generation occurs on the server side (in the API Route handler) once the request from the client is received.
  7. Use the package @aws-sdk/client-s3 to connect to the service using the credentials stored in the .env file, and also create a new PutObjectCommand.
  8. Call the getSignedUrl() method from @aws-sdk/request-presigned-url and pass the command and the client variable to get the URL, which the api route returns back to the client.
  9. Back in the client component, convert the image file to blob. You can use FileReader along with the Pica package to do it easily. You may also resize larger images if required.
  10. Once both the URL and image blob are available, send a fetch request to that URL with the blob as the request body.
  11. Make sure to set the HTTP method to PUT, also set the content type header to image file's mimeType.
  12. In order to show a progress bar, use XMLHttpRequest instead of fetch, as it fires an even called upload.onprogress.
  13. You can also use a state variable to set the progress's total and loaded values, both being of type number.
  14. Once the XHR is completed successfully, send another request to the API endpoint /api/make-public to set the object's ACL property to public-read. Otherwise, the image won't be viewable in the browser from the CDN URL. We need to do this separately because for some reason, Spaces doesn't respect the ACL value set in the PutObjectCommand while requesting the URL.
  15. This endpoint - /api/make-public - sends an additional PutObjectAclCommand to explicitly set the object's ACL value. And btw, the ACL value is of type - ObjectCannedACL.
  16. Also set another state variable to show the form's status - success or error. If it's success, also include the file data to display the uploaded image. Make use of conditional rendering in the JSX to handle state displays properly.

Structure of the project

|-- app/
|   |-- api/
|   |   |-- presigned-url/
|   |   |   |-- route.ts
|   |   |-- make-public/
|   |   |   |-- route.ts
|   |-- UploadForm.tsx
|   |-- spaces.ts
|   |-- interfaces.ts
|   |-- page.tsx
|   |-- layout.tsx
|-- .env.local
|-- .gitignored

The .env.local file

These are example values, replace them with your own.

SPACES_SECRET_KEY="LYclWcMTFFFZRsjvU1Bn9Yo5iZDb3s7bKFZTm9Bc7ec"
SPACES_ACCESS_KEY="DO00WFQEXTM4MF96UAH8"
SPACES_BUCKET="garden-store"
SPACES_ENDPOINT="https://blr1.digitaloceanspaces.com"
SPACES_CDN_URL="https://garden-store.blr1.cdn.digitaloceanspaces.com"

Interface definitions

Place these definitions in a file named interfaces.ts in the app folder.

export interface FileData {
    key: string,
    mimeType: string,
    size: number
}

export interface State {
    status: boolean | null,
    message: string,
    data?: FileData
}

export interface ImageBlobResult {
    status: boolean,
    message: string,
    imageBlob: Blob | null
}

export interface Progress {
    loaded: number,
    total: number
}

UploadForm component

This is how the UploadForm component looks like. It's a client component that you can call from the main page component.

"use client";

import { useState } from "react";
import Pica from "pica";
import { FileData, ImageBlobResult, Progress, State } from "./interfaces";

const pica = new Pica();

export function UploadForm() {

    const [state, setState] = useState<State>({status: null, message: ""});
    const [progress, setProgress] = useState<Progress>({loaded: 0, total: 0});

    /**
    Here goes the definitions for functions:
    1. imageToBlob()
    2. uploadObject()
    3. makeObjectPublic()
    4. handleOnSubmit()
    All of these are given in the below sections
    **/

    return (
        <>
        <h1>Upload an Image</h1>
        {
            state.status !== null &&
            <div className={`p-2 ${state.status === true ? 'bg-emerald-400' : 'bg-red-400'} `}>{state.message}</div>
        }

        {
            state.status === true && state?.data !== undefined &&
            <img width="600" src={`https://garden-store.blr1.cdn.digitaloceanspaces.com/${state.data.key}`} />
        }

        {
            progress.total !== 0 &&
            (() => {
                let percentage = Math.round((progress.loaded / (progress.total || 1)) * 100);

                return (
                    <div>{percentage}%</div>
                )
            })()
        }
        <form action="" onSubmit={handleOnSubmit} encType="multipart/form-data">
            <input type="file" name="file" id="file" />
            <button type="submit">Upload</button>
        </form>
        </>
    )
}

Form submission handling

    async function handleOnSubmit(ev:React.FormEvent<HTMLFormElement>) {
        try {
            ev.preventDefault();

            setState({status: null, message: ""});
            setProgress({loaded: 0, total: 0});

            const formData = new FormData(ev.target as HTMLFormElement);
            const file = formData?.get("file") as File;

            if(!file?.name) {
                throw new Error("Please select a file");
            }

            const data: FileData = {
                key: file.name,
                mimeType: file.type,
                size: file.size
            }

            const allowedTypes = ["image/jpeg", "image/png", "image/jpg"];

            if(!allowedTypes.includes(data.mimeType)) {
                throw new Error("Invalid file type, select a jpeg or png image");
            }

            const generatePreSignedUrlReq = await fetch("/api/presigned-url", {
                method: 'POST',
                body: JSON.stringify(data),
            });

            const {url} = await generatePreSignedUrlReq.json();
            const { imageBlob } = await imageToBlob(file);

            console.log(url);
            console.log(imageBlob);

            if(!url || !imageBlob) {
                throw new Error("error getting url and processing image");
            }

            await uploadObject(url, imageBlob, data);
            await makeObjectPublic(data);

            setState({ status: true, message: "image uploaded successfully", data });
            (ev?.target as HTMLFormElement).reset();

        }
        catch(error) {
            console.log((error as Error).message);
            setState({status: false, message: (error as Error).message});
        }
    }

Converting image to blob

This function goes inside UploadForm()

async function imageToBlob(image: File):Promise<ImageBlobResult> {
    return new Promise((resolve, reject) => {
        const reader = new FileReader();

        reader.readAsDataURL(image);

        reader.onload = async (e) => {
            const img = new Image();

            img.onload = async () => {
                const canvas = document.createElement("canvas");

                canvas.width = img.width;
                canvas.height = img.height;

                try {

                    const resizedCanvas = await pica.resize(img, canvas);
                    const imageBlob = await pica.toBlob(resizedCanvas, image.type, 0.8);

                    resolve({status: true, imageBlob, message: "image converted to blob"});
                }
                catch(error) {
                    reject({status: false, imageBlob: null, message: (error as Error).message });
                }
            }
            img.src = e.target?.result as string;
        }
    })
}

Uploading the object

This function goes inside UploadForm()

async function uploadObject(url: string, blob: Blob, data: FileData): Promise<State> {
    return new Promise((resolve, reject) => {

        setProgress((prevProgress) => (
            { ...prevProgress, total: blob.size }
        ));

        const xhr = new XMLHttpRequest();

        xhr.open("PUT", url, true);
        xhr.setRequestHeader("Content-Type", data.mimeType);

        xhr.upload.onprogress = async (ev: ProgressEvent) => {
            if(ev.lengthComputable) {
                setProgress((prevProgress) => (
                    { ...prevProgress, loaded: ev.loaded }
                ))
            }
        }

        xhr.onreadystatechange = async () => {
            if(xhr.readyState === 4) {
                if(xhr.status === 200) {
                    resolve({ status: true, message: "image uploaded" });
                }
                else {
                    reject({ status: false, message: "error uploading image" });
                }
            }
        }

        xhr.onerror = () => {
            reject({ status: false, message: "Network error occurred" });
        }

        xhr.send(blob);
    })
}

Setting the object to public

This function goes inside UploadForm()

async function makeObjectPublic(data: FileData):Promise<void> {
    const response = await fetch("/api/make-public", {
        method: 'POST',
        body: JSON.stringify(data)
    });

    const { status, message } = await response.json();

    if(!status) {
        throw new Error (message);
    }
}

S3 function definitions (server-side)

This goes inside a file called spaces.ts:

import { ObjectCannedACL, PutObjectAclCommand, PutObjectCommand, S3 } from "@aws-sdk/client-s3";
import { FileData } from "./interfaces";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";

export const S3Client = new S3({
    region: "ap-south-1",
    endpoint: process.env.SPACES_ENDPOINT as string,
    credentials: {
        accessKeyId: process.env.SPACES_ACCESS_KEY as string,
        secretAccessKey: process.env.SPACES_SECRET_KEY as string,
    }
});

export async function generatePreSignedUrl({key, mimeType}: FileData):Promise<string> {
    const bucketParams = {
        Bucket: process.env.SPACES_BUCKET as string,
        Key: key,
        ContentType: mimeType
    }

    const command = new PutObjectCommand(bucketParams);

    const url = await getSignedUrl(S3Client, command, {expiresIn: 3600});

    return url;
}

export async function makeObjectPublic({ key }: FileData) {
    const bucketParams = {
        Bucket: process.env.SPACES_BUCKET as string,
        Key: key,
        ACL: "public-read" as ObjectCannedACL
    }

    const command = new PutObjectAclCommand(bucketParams);

    try {
        await S3Client.send(command);
    }
    catch(error) {
        throw new Error(`could not update ACL rules, ${(error as Error).message}`);
    }
}

Route Handler to generate the presigned URL

This goes inside /api/presigned-url.

import { NextResponse } from "next/server";
import { generatePreSignedUrl } from "@/app/spaces";
import { FileData } from "@/app/interfaces";

export async function POST(request: Request): Promise<Response> {
    const data: FileData = await request.json();

    try {
        const url = await generatePreSignedUrl(data);
        return NextResponse.json({url});
    }
    catch(error) {
        console.log((error as Error).message);
        return NextResponse.json({error: (error as Error).message});
    }
}

Route Handler to set the object's ACL to public-read

This goes inside /api/make-public

import { NextResponse } from "next/server";
import { makeObjectPublic } from "@/app/spaces";
import { FileData } from "@/app/interfaces";

export async function POST(request: Request): Promise<Response> {
    try {
        const data: FileData = await request.json();
        await makeObjectPublic(data);
        return NextResponse.json({ status: true, message: "object is now publicly readable" });
    }
    catch(error) {
        return NextResponse.json({ status: true, message: (error as Error).message });
    }
}

Conclusion

The above sections should give you the idea on how to upload an image file to Spaces storage. You can use the same method for any S3-compatible storage, and also for other file types, with some modifications.