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:
- Create a Spaces bucket and generate the API keys - access key, and secret key.
- Store the keys in an
.env
file in at the root of your Next.js application. - 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. - Once the file input is received, check the mimeType to ensure that it is either JPEG or PNG. Otherwise, throw an error.
- If it is an image file, send an API request to
/api/presigned-url
along with the file data (key, and mimeType). - The presigned URL generation occurs on the server side (in the API Route handler) once the request from the client is received.
- Use the package
@aws-sdk/client-s3
to connect to the service using the credentials stored in the.env
file, and also create a newPutObjectCommand
. - 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. - Back in the client component, convert the image file to blob. You can use
FileReader
along with thePica
package to do it easily. You may also resize larger images if required. - Once both the URL and image blob are available, send a fetch request to that URL with the blob as the request body.
- Make sure to set the HTTP method to PUT, also set the content type header to image file's mimeType.
- In order to show a progress bar, use
XMLHttpRequest
instead of fetch, as it fires an even calledupload.onprogress
. - You can also use a state variable to set the progress's
total
andloaded
values, both being of typenumber
. - Once the XHR is completed successfully, send another request to the API endpoint
/api/make-public
to set the object's ACL property topublic-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 thePutObjectCommand
while requesting the URL. - This endpoint -
/api/make-public
- sends an additionalPutObjectAclCommand
to explicitly set the object's ACL value. And btw, the ACL value is of type -ObjectCannedACL
. - 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.