If you’ve ever added file uploading to a web application, you know it can be a tricky endeavor. There’s a lot of work involved with providing a good user experience for uploading images on both the backend and frontend of a web application. Thankfully, along with all the other great features for optimizing your images, this is something that Cloudinary APIs and client libraries simplify. The Cloudinary service covers the backend part and the Next Cloudinary package includes widgets to handle the client bits.
In this blog post, we’ll build a simple avatar image uploader component in a web app powered by the NextJS App Router with React Server Components. Cloudinary will do most of the heavy lifting, but we’ll still have some integration work to do in our server-rendered React app.
There are a few things we’ll need to create our demo avatar uploader:
- A Cloudinary account.
- NextJS App Router app.
- next-cloudinary and cloudinary packages.
Once we have our Cloudinary account, we can generate our NextJS app.
npx create-next-app@latest
Code language: CSS (css)
You’ll be prompted to choose some options for your new application. We need to provide a name and enable the App Router. I’ve also enabled TypeScript and Tailwind for this demo app.Once our app is generated, we can install next-cloudinary and cloudinary packages.
cd avatar-app
npm i next-cloudinary cloudinary
To render our avatar images uploaded to Cloudinary, we’ll need to whitelist the host location of our image files in our next.config.mjs config file. This is required for any hostnames for images that we want to render with the next Image component.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "res.cloudinary.com",
},
],
},
};
export default nextConfig;
Code language: JavaScript (javascript)
Next, we’ll need to create a .env
file in our project root to add some environment variables. We have to navigate to a few different places in our Cloudinary account to collect all these.
- You can find the cloud name on the Getting Started page in the dashboard.
- We can get an upload preset in the Settings > Upload page of the dashboard.
- Finally, we can get our API_KEY and API_SECRET in Settings > Access Keys.
# These are fake values
NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME=abc123xyz
NEXT_PUBLIC_UPLOAD_PRESET=ml_default
CLOUDINARY_API_KEY=568392749283619
CLOUDINARY_API_SECRET=7HJKhR1MjKXPqhe9XjdFM1yxZZX
Code language: PHP (php)
The cloudinary
package, API_KEY
, and API_SECRET
are specifically for creating signing keys to secure our image and happen in our backend.
With all these pieces in place, we’re good to move forward.
For the purposes of our demo, let’s imagine that we have an auth system and database from which we load the current user. On a NextJS development server, we can just use a global variable as a mock database since it runs on a stateful Node server. In the src directory, I’ve created a fake database for the application to use.
// db.ts
declare global {
var DB: { currentUser: User };
}
export interface User {
name: string;
email: string;
avatar?: string;
}
export const DB = (global.DB = {
currentUser: {
name: "John Smith",
email: "jsmith@email.com",
avatar: undefined,
},
});
const delay = (ms: number = 80) =>
new Promise((resolve) => setTimeout(resolve, ms));
export async function getCurrentUser() {
await delay();
return DB.currentUser;
}
export async function updateUser(user: Partial<User>) {
await delay();
DB.currentUser = Object.assign({}, DB.currentUser, user);
}
Code language: JavaScript (javascript)
We have async functions with an artificial delay for getting the current user and updating the user object.
Our app has only a home page. The home page (page.tsx /
) is a React Server Component. To integrate our avatar uploader we’ll create a client component at components/avatar-uploader.tsx
.
We’ll work backwards and start with the AvatarUploader component. The first line of our file declares that this component is a client component which means it’s the traditional type of component that we’re used to using in React. We can have state, effects, and access browser APIs. Because most existing packages in the React ecosystem weren’t built for server components, we have to be mindful about whether or not they’re using client-only APIs. If they are, we might have to wrap them in a file that declares use client at the top.
// components/avatar-uploader.tsx
"use client";
import { CldUploadWidget } from "next-cloudinary";
interface AvatarUploaderProps {
onUploadSuccess: (url: string) => void;
}
export function AvatarUploader({ onUploadSuccess }: AvatarUploaderProps) {
return (
<CldUploadWidget
uploadPreset={process.env.NEXT_PUBLIC_CLOUDINARY_UPLOAD_PRESET}
signatureEndpoint="/api/sign-cloudinary-params"
onSuccess={(result) => {
if (typeof result.info === "object" && "secure_url" in result.info) {
onUploadSuccess(result.info.secure_url);
}
}}
options={{
singleUploadAutoClose: true,
}}
>
{({ open }) => {
return (
<button
type="button"
onClick={() => open()}
className="rounded-md bg-indigo-600 px-2.5 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
Upload Avatar
</button>
);
}}
</CldUploadWidget>
);
}
Code language: JavaScript (javascript)
Our component takes a single prop onUploadSuccess. We can use this prop to pass a Server Action function to this client component to handle updating our user with the uploaded avatar url. This component has to be a client component because the CldUploadWidget is a traditional client side component. It depends on client specific code for rendering the popup widget and creating an AJAX request to upload the file.
Cloudinary’s CldUploadWidget
does all the heavy lifting for uploading the image on the client. It takes a render prop that we render a button that triggers the upload widget UI when clicked.
The important pieces are the props and configuration that we provide to CldUploadWidget
:
uploadPreset
. This is the value that we took from our Cloudinary settings. This lets us provide some default options for optimizing our image through Cloudinary.signatureEndpoint
. We’ll add some security by creating signed URLs for our images so that they can only be accessed through our application. This prop just takes an endpoint that we’ll handle the signing on the server side.onSuccess
. A callback function when the image upload has been completed. We’ll check the result to make TypeScript happy and pass our signed URL to our components onUploadSuccess prop.options
. Since we’re only uploading a single image, we’ll pass the singleUploadAutoClose: true value so the upload widget closes when the upload is completed.
This is a pretty simple setup. The docs are comprehensive if we want to customize our widget or other options a bit more.
In our CldUploadWidget, we passed an API URL path /api/sign-cloudinary-params for the signatureEndpoint prop. We need to create this API endpoint to handle the signing.
We’ll create a file at the path api/sign-cloudinary-params/route.ts
. The docs provide the code for us.
import { v2 as cloudinary } from "cloudinary";
cloudinary.config({
cloud_name: process.env.NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET,
});
export async function POST(request: Request) {
const body = await request.json();
const { paramsToSign } = body;
const signature = cloudinary.utils.api_sign_request(
paramsToSign,
process.env.CLOUDINARY_API_SECRET!
);
return Response.json({ signature });
}
Code language: JavaScript (javascript)
I mentioned before that the home page is a Server Component. We’re able to fetch our user from the database in our component and also define our server action that handles the upload success callback to update our users avatar in the database.
Our goal is to have our home page render the user’s newly updated avatar image once the upload completes. In client-side React, we could just use some state to handle this. The upload completes, we set the state, and it re-renders the component showing the new image. This flow is a little bit different in a Server Component.
Here’s the code for our home page. It’s a simple UI that shows a welcome message with a placeholder or avatar image rendered below, and an upload button.
import Image from "next/image";
import { revalidatePath } from "next/cache";
import { getCurrentUser, updateUser } from "@/db";
import { AvatarUploader } from "@/components/avatar-uploader";
export default async function Home() {
const user = await getCurrentUser();
async function saveAvatar(url: string) {
"use server";
await updateUser({ avatar: url });
revalidatePath("/");
}
return (
<main className="p-24 flex flex-col justify-center items-center">
<h1 className="text-4xl font-bold my-12">Welcome back, {user.name}</h1>
<div className="flex flex-col items-center space-y-4">
{user.avatar ? (
<Image
src={user.avatar}
width={288}
height={288}
className="rounded-full"
alt="Your avatar"
/>
) : (
<div className="bg-gray-300 w-72 h-72 rounded-full" />
)}
<div className="flex items-center justify-center gap-x-4">
<AvatarUploader onUploadSuccess={saveAvatar} />
</div>
</div>
</main>
);
}
Code language: JavaScript (javascript)
Here’s how it looks by default before we upload our image:
Looking at the component code, before we actually return our JSX
, we have an async function that runs on the server and fetches the user from the database, and a server action function that handles updating the user in the database. We’ll conditionally render the avatar if it’s set. Otherwise, we’ll show a gray circle placeholder. We’ll render our AvatarUploader
client component and pass it our saveAvatar
server action. When the onSuccess
callback is triggered in our Cloudinary widget component, our server action will update the user in the database with the signed URL of the uploaded image.
Even though we’re not setting any state for the user or avatar, it’s just being fetched in our server component and automatically renders in the page when the upload is completed. This happens because of the revalidatePath("/")
call in our saveAvatar
action.
async function saveAvatar(url: string) {
"use server";
await updateUser({ avatar: url });
revalidatePath("/");
}
Code language: JavaScript (javascript)
This tells NextJS that some data on which this page depends has potentially changed. NextJS will stream in the updated UI from the server and update our client without triggering a page refresh.
We’ve managed to provide a client app-like experience on a server-rendered page thanks to Cloudinary and NextJS! We just have some straightforward React code and a couple of functions that fetch and update some data. We also integrated secure and optimized avatar images into our application without much friction at all.
Learn more about Cloudinary features to optimize and secure images. And if you found this blog post helpful and want to discuss it in more detail, head over to the Cloudinary Community forum and its associated Discord.