How to Upload Images to Cloudinary with Remix App

Banner for a MediaJam post

Christian Nwamba

Remix is the newest trending JavaScript framework in the ecosystem right now. It is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. Personally, i like the fact that Remix is carrying us back to web development foundations with amazing features such as:

  • Nested Routes: Remix loads data in parallel on the server and sends a fully formed HTML document. This almost eliminates loading states and makes your site super lightning fast. You might not need Skeleton UI anymore. This is good news to most frontend developers like me.
  • Simple: If you know HTML, you will enjoy working with Remix. For example, you can implement a form without JavaScript. Remix runs actions server side, revalidates data client side, and even handles race conditions from resubmissions.
  • Error Handling: Remix makes Error handling easy with Error boundaries. While we are still waiting for React to provide Error boundaries for functional components, Remix handles errors while Server Rendering and while Client Rendering too.
  • Actions and Loaders: After Nested Routes, this is the next most amazing feature of Remix. In Remix, you use actions for mutations and loaders for retrieving data. Interesting fact about Loaders is that you can do most of your data transformation and calculations there, like check if a list is empty, limit the number of records, only send specific attributes, so your React component just receives the data and renders it, no logic needed. You can think of loaders as “GET” request handlers, the code snippet below shows how loaders work:
1import { json } from "@remix-run/node"; // or "@remix-run/cloudflare"
3export const loader = async () => {
4 // The `json` function converts a serializable object into a JSON response
5 // All loaders must return a `Response` object.
6 return json({ ok: true });

The loader() function is only run on the server. Later in this article, we will be making use of loaders.

  • Lightning page speed: With nested routes, instant transitions leveraging distributed systems and native browser features, built on web fetch api, and cloudfare workers, Remix could have 99 problems, but page speed ain’t one.

Cloudinary is a platform on which we can upload, store, manage, transform, and deliver images and videos for web and mobile applications. Cloudinary provides an exhaustive API for uploading media (including images, video and audio). The Upload API enables you to upload your media assets (resources) and provides a wide range of functionality, including basic and advanced asset management, metadata management, and asset generation. Chakra UI is a simple, modular and accessible component library that gives you the building blocks you need to build your React applications.

In this tutorial, we will be building a Remix app that allows us to upload images to Cloudinary.


You will need the following to follow this tutorial:

  • Nodejs >=v14 installed
  • Knowledge of JavaScript
  • A code editor (preferably VSCode)

The complete code is on Codesandbox.

Disclaimer: Codesanbox does not support Remix out of the box yet. To view this demo on Codesandbox, fork the project, provide your cloudinary credentials, open the terminal, and run these commands

1cd my-remix-app
2npm run dev

The app will be running on port 3000.

Getting Started

Let’s begin setting up our Remix project. Run this command to bootstrap a new Remix project

2npx create-remix@latest

When the script is done installing, you’ll be prompted with some questions, make sure you choose these options:

2? Where would you like to create your app? remix-upload-image
3? What type of app do you want to create? Just the basics
4? Where do you want to deploy? Choose Remix App Server if you're unsure; it's easy to change deployment targets. Remix App Server
5? TypeScript or JavaScript? JavaScript
6? Do you want me to run `npm install`? Yes

Once it’s done installing dependencies, run this command to change directory to our project directory:

2cd remix-upload-image

Open the folder with your code editor, and let’s go through the directory structure of our remix app.

4├── app
5│ ├── entry.client.jsx
6│ ├── entry.server.jsx
7│ ├── root.jsx
8│ └── routes
9│ └── index.jsx
10├── package-lock.json
11├── package.json
12├── public
13│ └── favicon.ico
14├── remix.config.js
15└── jsconfig.json

app: this folder is where all the Remix code goes app/entry.client.tsx: this is the first file that runs when the app loads in the browser app/entry.server.tsx: this is the first file that runs when a request is made to the server app/root.tsx: this is the root of our remix application. Similar to index.tsx in Next app/routes : just like Nextjs, this folder handles routing and pages public : this is where you put static files such as images, fonts, etc remix.config.js: similar to next.config.js, this has all Remix configurations

Let’s write our first Hello World program. Navigate to app/routes and delete index.jsx. Go to app/root.jsx, delete everything, and add these lines of code:

3import { LiveReload } from "@remix-run/react";
5export default function App() {
7return (
8 <html lang="en">
9 <head>
10 <meta charSet="utf-8" />
11 <title>Remix Upload Images</title>
12 </head>
14 <body>
15 Hello Remix world
16 <LiveReload />
17 </body>
18 </html>

Here, LiveReload handles hot reload in Remix.

Go ahead and run npm run dev in your terminal to serve the development build. You should get something like this:

1➜ remix-upload-image npm run dev
3> dev
4> remix dev
6Watching Remix app in development mode...
7💿 Built in 736ms
8Remix App Server started at http://localhost:3000 (

Built in 736ms! Navigate to http://localhost:3000 in your browser and you should see your hello world program.

Let’s go ahead to implementing the cloudinary image uploading functionalities.

Implementing the Image uploader

Firstly, we’ll update our app/root.jsx to look like this:

3import {
4 Links,
5 LiveReload,
6 Meta,
7 Outlet,
8 Scripts,
9 ScrollRestoration,
10} from "@remix-run/react";
11import stylesUrl from "~/styles/global.css";
13export const links = () => {
14 return [{ rel: "stylesheet", href: stylesUrl }];
15 };
17export const meta = () => ({
18 charset: "utf-8",
19 title: "Remix Upload image",
20 viewport: "width=device-width,initial-scale=1",
21 });
23export default function App() {
24 return (
25 <html lang="en">
26 <head>
27 <Meta />
28 <Links />
29 </head>
30 <body>
31 <Outlet />
32 <ScrollRestoration />
33 <Scripts />
34 <LiveReload />
35 </body>
36 </html>
37 );
40export function ErrorBoundary({ error }) {
41 return (
42 <div className="error-container">
43 <h1>App Error</h1>
44 <pre>{error.message}</pre>
45 </div>
46 );

Here, we import components from the @remix-run/react package and set an Error Boundary. Most important of those components are the <Links /> and <Meta />. The <Link /> component handles all link exports all through the app and the <Meta /> component handles all meta exports on all routes while <Outlet /> gives room for Children routes. Earlier, i mentioned that one of my favourite features of Remix is how it handles errors, well you can see how easy it is to create ErrorBoundaries. We have a global.css ****file referenced in our code, let’s create it now. Create a styles/global.css directory and add these lines of code:

1/* styles/global.css */
3body {
4 background-color: #000000;
5 color: #ffffff;
6 width: 1200px;
7 margin: auto;
8 font-family: sans-serif;
11.error-container {
12 background-color: hsla(356, 77%, 59%, 0.747);
13 border-radius: 0.25rem;
14 padding: 0.5rem 1rem;
16a {
17 color: wheat;
18 text-decoration: none;
21a:hover {
22 text-decoration: underline;
23 opacity: 0.8;

This will be the styles used throughout our app.

Let’s go ahead to create our Index route. Create a app/route/index.jsx directory and add the following lines of code:

3import { Link } from "@remix-run/react";
5export default function Index() {
6 return (
7 <div>
8 <h1> Remix image upload </h1>
9 <p>
10 This is a Remix app for uploading images to cloudinary
11 </p>
12 <Link to="/cloudinary-upload"> Upload Images here </Link>
13 </div>
14 );

This is going to be our / route and page. Nothing much is going here except importing the @remix-run/react Link. It represents the <a> ****anchor tag and it’s the main method of navigation in a remix app.

Let’s go ahead to create the main thing, the cloudinary upload route. Create a cloudinary.jsx in the route folder and add these lines of code:

3import {
4 json,
5 unstable_composeUploadHandlers as composeUploadHandlers,
6 unstable_createMemoryUploadHandler as createMemoryUploadHandler,
7 unstable_parseMultipartFormData as parseMultipartFormData,
8} from "@remix-run/node";
9import { Form, useActionData } from "@remix-run/react";
10import { uploadImage } from "~/utils/utils.server";
11import formStylesUrl from "~/styles/form.css";
13export const links = () => {
14 return [{ rel: "stylesheet", href: formStylesUrl }];
17export const action = async ({ request }) => {
18 const uploadHandler = composeUploadHandlers(
19 async ({ name, data }) => {
20 if (name !== "img") {
21 return undefined
22 }
23 const uploadedImage = await uploadImage(data)
24 return uploadedImage.secure_url;
25 },
26 createMemoryUploadHandler()
27 );
29 const formData = await parseMultipartFormData(request, uploadHandler);
30 const imgSource = formData.get("img");
31 const imgDescription = formData.get("description");
33 if (!imgSource) {
34 return json({
35 error: "something is wrong",
36 });
37 }
38 return json({
39 imgSource, imgDescription
40 });
43export default function Index() {
44 const data = useActionData();
45 return (
46 <>
47 <Form method="post" encType="multipart/form-data" id="upload-form">
48 <div>
49 <label htmlFor="img"> Image: </label>
50 <input id="img" type="file" name="img" accept="image/*" />
51 </div>
52 <div>
53 <label htmlFor="description"> Image description: </label>
54 <input id="description" type="text" name="description" />
55 </div>
56 <div>
57 <button type="submit"> Upload to Cloudinary </button>
58 </div>
59 </Form>
61 {data?.errorMsg && <h3>{data.errorMsg}</h3>}
62 {data?.imgSource && (
63 <>
64 <h2>Uploaded Image: </h2>
65 <img src={data.imgSource} alt={data.imgDescription || "Upload result"} />
66 <p>{data.imgDescription}</p>
67 </>
68 )}
69 </>
70 )
73export function ErrorBoundary({ error }) {
74 return (
75 <div className="error-container">
76 <pre>{error.message}</pre>
77 </div>
78 );

Let’s break this into snippets.

Firstly, we imported some functions from the nodejs part of remix.

  • json converts a response object to a JSON object,
  • unstable_composeUploadHandlers: this is an upload handler that accepts our HTML input field name as parameter and file bytes from the uploaded images as data.
  • unstable_createMemoryUploadHandler: this is another upload handler that stores streamed multipart/form-data parts in memory.

These functions makes up our uploadHandler function. Before we leave the uploadHandler let’s take a look at this code snippet:

2const uploadedImage = await uploadImage(data)
3return uploadedImage.secure_url;

The uploadImage(data) takes in the image bytes as parameter, returns a Promise that resolves if our image has been saved to the cloudinary folder we’ll specify soon and rejects if there’s an error. If an image is uploaded successfully, it returns an object that looks like this for us to use:

2 asset_id: '1c80a31297c4748b7d655190d1e5023b',
3 public_id: 'remixImages/nrubhrrcv030zhiulzzb',
4 version: 1657796627,
5 version_id: 'e78d675b8199040f471c89ce1903a8b0',
6 signature: 'f50122a9819e3458630afde2927dab615f05437c',
7 width: 1000,
8 height: 1333,
9 format: 'jpg',
10 resource_type: 'image',
11 created_at: '2022-07-14T11:03:47Z',
12 tags: [],
13 bytes: 204402,
14 type: 'upload',
15 etag: 'ccd7e9f2ecda9e91de437ce15a9464b5',
16 placeholder: false,
17 url: '',
18 secure_url: '',
19 folder: 'remixImages',
20 original_filename: 'file',
21 api_key: '<api-key>'

We’ll be creating the utils.server.js very soon. Let’s look at another code snippet here:

1const formData = await parseMultipartFormData(request, uploadHandler);
2const imgSource = formData.get("img");
3const imgDescription = formData.get("description");
5if (!imgSource) {
6 return json({
7 error: "something is wrong",
8 });
10return json({
11 imgSource, imgDescription

The unstable_parseMultipartFormData is a remix utility that handles multipart formdata file uploads instead of using request.formData. It returns the field value that our uploadHandler returns. For instance, the value of imgSource will the URL of the image uploaded to cloudinary.

Finally, all of this is wrapped by Remix actions. Remember what we said about Actions and loaders earlier, here we use actions. It’s important that the action({ request }) is an async function that comes before the loader and your template. So, how do we access the data from our actions right? const *data* = *useActionData*``(); This line of code does that. The useActionData() hook returns the JSON parsed data from our action. If you’ve understood through these parts, you’re awesome! Let’s create two files that we called in our route. First, create a form.css file in the styles directory and add these lines of css:

3#upload-form {
4 width: 280px;
5 margin: 5rem auto;
6 background-color: #fcfcfc;
7 padding: 20px 50px 40px;
8 box-shadow: 1px 4px 10px 1px #aaa;
10#upload-form * {
11 box-sizing: border-box;
13#upload-form input {
14 margin-bottom: 15px;
16#upload-form input[type=text] {
17 display: block;
18 height: 32px;
19 padding: 6px 16px;
20 width: 100%;
21 border: none;
22 background-color: #f3f3f3;
24#upload-form label {
25 color: #777;
26 font-size: 0.8em;
28#upload-form button[type=submit] {
29 display: block;
30 margin: 20px auto 0;
31 width: 150px;
32 height: 40px;
33 border-radius: 5px;
34 border: none;
35 color: #eee;
36 font-weight: 700;
37 box-shadow: 1px 4px 10px 1px #aaa;
38 background: #207cca; /* Old browsers */
39 background: -moz-linear-gradient(left, #207cca 0%, #9f58a3 100%); /* FF3.6-15 */
40 background: -webkit-linear-gradient(left, #207cca 0%,#9f58a3 100%); /* Chrome10-25,Safari5.1-6 */
41 background: linear-gradient(to right, #207cca 0%,#9f58a3 100%); /* W3C, IE10+, FF16+, Chrome26+, Opera12+, Safari7+ */
42 filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#207cca', endColorstr='#9f58a3',GradientType=1 ); /* IE6-9 */
44img {
45 width: 100%;
47input#img {
48 color: red;

Another awesome thing about Remix is how it handles styles. When we navigate to the cloudinary-upload it loads this form.css file and when we leave the route, it unloads the styles. Take a second to imagine how that improves the overall site speed.

Let’s finally create the utils/util.server.js directory. Add the following lines of code:

1// utils/util.server.js
3import cloudinary from "cloudinary";
4import { writeAsyncIterableToWritable } from "@remix-run/node";
7 cloud_name: process.env.CLOUD_NAME,
8 api_key: process.env.API_KEY,
9 api_secret: process.env.API_SECRET,
12async function uploadImage(data) {
13 const uploadPromise = new Promise(async (resolve, reject) => {
14 const uploadStream = cloudinary.v2.uploader.upload_stream(
15 { folder: "remixImages" },
16 (error, result) => {
17 if (error) {
18 reject(error)
19 return;
20 }
21 resolve(result)
22 }
23 )
24 await writeAsyncIterableToWritable(data, uploadStream);
25 });
26 return uploadPromise;
29export { uploadImage }

This is is the server side of Remix. The file above is a server module. It’s best practice in Remix to handle all server side code in a *.server.js module, Remix also uses "tree shaking" to remove server code from browser bundles. One last thing, create a .env file and add these details from your cloudinary dashboard:

1// .env
2CLOUD_NAME=cloud name here
3API_KEY= api key here
4API_SECRET= api secret here

Now go ahead and run your development server with:

1npm run dev

You should be presented with these screens:

Go ahead to cloudinary and check our Media Uploads for the remixImage folder and our cat image.


In this guide, we learned about Remix and it’s top features, we also explored cloudinary and used it upload API to build a Remix app that enables users to upload images to Cloudinary.

Further Reading

Happy Coding!

Christian Nwamba

Developer Advocate at AWS

A software engineer and developer advocate. I love to research and talk about web technologies and how to delight customers with them.