Hi, I struggled to find an up to date article on this subject, so I decided to document how I solved this in one of my personal projects.
Like in any modern web app, file uploads, in particular image uploads are a very common necessity.
Cloudinary kept popping onto my radar, but I never found the time to look into it. So when I started building my latest project it was the perfect opportunity. It is a Digital Menu builder for restaurants. There are a dozen of them in the wild already, but It seamed like a great type of project to learn. My main goal was to get more familiar with the new App router in Next.js and why not try cloudinary along the way.
I wont get to deep into the new Next.js app router in this article, thats something for another time. But will focus mostly on setting up cloudinary and creating an upload widget which we will use to do signed uploads.
Table of content:
Creating a cloudinary account
Had over to cloudinary.com and sign up for a new account.
After which you should see the next section under the Dashboard tab.
Copy the Cloud Name, Api Key and Api Secret for later use.
Now head over to the setting page:
Now go to the Upload tab on the left navigation bar and under Upload presets click on Add Upload preset.
Give it a unique name or leave as is provided by default.
The only thing that I would like to do is limit the images to a maximum of 1000px width and height. To achieve this, head over to the Upload Manipulations tab and under Incoming Transformations click on the Edit button.
Here is my config
That should be all we need to do in the cloudinary dashboard to get ready for uploading our images.
Creating the upload widget
Upload widget behaviour:
The upload widget will be a button that when we click on opens up the File Explorer for us to select the image to upload.
It will start the upload right after we have selected an image.
It will provide a onUploadComplete and isUploading props which we can use in our parent component as well as some other properties.
Little heads-up before we dive into building the component, I will be using Typescript, Tailwind with DaisyUI for styling and react-icons library.
Setup the basic markup of the component
//props expected from the upload widget component
interface UploadImageProps {
onUploadComplete: (url: string) => void; //event fired when upload is complete, gets the public image url returned
isUploading?: (val: boolean) => void; //if the parent comonent needs to take track if image is being uploaded
imageName?: string; // the name of the image as being stred on the cloudinary server
options?: { // some options used when uploading to cloudinary
upload_preset?: string; //the preset if we have one
folder?: string; //folder to which we will upload the image to
};
}
///////////////
import {FaUplaod} from 'react-icons/fa'
export default function UploadWidget({
onUploadComplete,
isUploading,
imageName,
options,
}: UploadImageProps) {
return (
<>
<input style={{display:'none'}} type="file" id="image-upload" />
<label htmlFor="image-upload">
<span>Upload Image</span>
<FaUpload />
</label>
</>
)
}
Now lets add some local state to the component.
...
export default function UploadWidget() {
const [isUploadingImage, setIsUploadingImage] = useState(false)
const [image, setImage] = useState<File | null>(null)
const [prevImage, setPrevImage] = useState<File | null>(null);
return (
...
The isUploadingImage variable we will use to show an upload spinner. The image variable is used to store the selected image, and prevImage is used to check if the image has changed so we can make the upload request.
Next step is to set a onChange event on the input element to open the File Explorer.
...
<input
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.target && e.target?.files?.length) {
setImage(e.target.files[0]);
}
}}
style={{ display: "none" }}
type="file"
id="image-upload"
/>
...
Lets initiate a upload function once a new image is selected. We will do this in a useEffect right before the return of our jsx
...
useEffect(() => {
if (prevImage !== image) {
setPrevImage(image);
uploadImage(); //still need to create this function
}
}, [image])
rerturn (
...
and the uploadImage function looks like this: Will explain inner of function with comments
async function uploadImage() {
if (!image) return;
let reqSignBody = {};
if (imageName) {
reqSignBody = {
public_id: imageName,
...options,
};
}
//we will create this api endpoint later
//is used to sign the upload request params
const signResponse = await fetch("/api/sign-cloudinary-params", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(reqSignBody),
});
//below we create a FormData instance and combine the image file with the returned signed params to upload the image
const signData = await signResponse.json();
const data = new FormData();
data.append("file", image);
data.append("api_key", signData.apikey);
data.append("timestamp", signData.timestamp);
data.append("signature", signData.signature);
if (options?.upload_preset) {
data.append("upload_preset", options.upload_preset);
}
if (options?.folder) {
data.append("folder", options.folder);
}
if (imageName) {
data.append("public_id", imageName);
}
try {
isUploading && isUploading(true);
setIsUploadingImage(true);
const res = await fetch(
`https://api.cloudinary.com/v1_1/dgwhzqt43/image/upload/`,
{
method: "POST",
body: data,
}
);
const resData = await res.json();
isUploading && isUploading(false);
setIsUploadingImage(false);
onUploadComplete(resData.url);
} catch (e) {
isUploading && isUploading(false);
setIsUploadingImage(false);
console.log(e);
}
}
Now we need to make some slight adjustments on the jsx, so the final markup looks like:
...
return (
<>
<input
onChange={(e: ChangeEvent<HTMLInputElement>) => {
if (e.target && e.target?.files?.length) {
setImage(e.target.files[0]);
}
}}
style={{ display: "none" }}
type="file"
id="image-upload"
/>
<label
htmlFor="image-upload"
className={`btn ${isUploadingImage ? "btn-disabled" : ""}`}
>
{isUploadingImage ? <span>Uploading</span> : <span>Upload Image</span>}
{isUploadingImage ? (
<span className="loading loading-spinner"></span>
) : (
<FaUpload />
)}
</label>
</>
);
...
Crating a api route for signing requests
Since we are using the new Next.js api router, go into the app -> api
folder and crate another folder named sign-cloudinary-params
and a route.ts
file in it.
Edit the .env
file, and add the values we retrieved from the cloudinary dashboard:
CLOUDINARY_API_SECRET=your_api_secret
CLOUDINARY_API_KEY=your_api_key
Content of route.ts
looks like:
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({
cloud_name: "your_cloud_name", //the cloud name that we retrived at fist step from the cloudinary dashboard
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
function signUploadForm(params?: {
public_id?: string;
timestamp?: number | string;
upload_preset?: string;
}) {
const timestamp = Math.round(new Date().getTime() / 1000);
const signature = cloudinary.utils.api_sign_request(
{
timestamp,
...params,
},
cloudinary.config().api_secret || ""
);
return { timestamp, signature };
}
export async function POST(req: Request) {
try {
const body = await req.json();
const sig = signUploadForm(body);
return new Response(
JSON.stringify({
signature: sig.signature,
timestamp: sig.timestamp,
cloudname: cloudinary.config().cloud_name,
apikey: cloudinary.config().api_key,
}),
{ status: 200 }
);
} catch (e) {
return new Response(null, { status: 500 });
}
}
And thats it. Now we can use the UplaodWidget
component like this as an example of uploading a avatar image for a user:
import UplaodWidget from './UploadWidget'
interface UserInterface {
id?:string;
image: string | null;
}
export default Page() {
const [user, setUser] = useState<UserInterface | null>(null)
async function updateUserInDatabase() {
// update login here
}
return (
<div>
<UploadWidget
imageName={formState.id + '-avatar'}
onUploadComplete={async (url: string) => {
setUser((prev) => {
return { ...prev, image: url };
});
await updateUserInDatabase();
}}
options={{ folder: "folder_of_user_avatars" }}
/>
</div>
)
}
Thanks for reading, hope this could help anyone 😄.