Image Upload to Strapi via REST API with Next.js and Postman

Theodore Kelechukwu Onyejiaku - Oct 30 - - Dev Community

Introduction to Media Upload in Strapi via REST API

Media uploads and image management is a key feature in modern applications. Strapi CMS offers flexible media upload capabilities through its REST API, making it an ideal tool for developers looking to integrate dynamic media handling into their projects. Thus, you can find the right asset and edit and reuse it in your websites.

In this tutorial, we will learn how to upload images to Strapi headless CMS using the REST API with Postman and Next.js. Using these tools, you’ll learn how to handle media efficiently in Strapi, equipping you with practical, scalable skills for content management.

Let's dive in!

Prerequisites

You will need to equip yourself with the following to proceed.

Outline

  • Introduction to Media Upload in Strapi via REST API
  • Prerequisites
  • Outline
  • Github Repo
  • What You Will Build: An Image Upload System in Strapi
  • Key Terms and Explanation
  • Strapi Media Library and the Upload Plugin
  • How to Upload Images in Strapi
  • Create a Strapi Application
  • Create a Next.js Application
  • Upload Single or Multiple Images to Strapi
  • Link an Image to an Existing Entry in Strapi
  • Display Linked Images and Food Entries
  • Upload a file at Entry Creation
  • View, Delete, and Update Images
  • Upload Image from an API controller in Strapi
  • Image Upload to Strapi using Postman
  • Conclusion

Github Repo

The source code for this project can be found here.

Also, in the repo above, you can find the images, in the images folder, we used for this tutorial.

What You Will Build: An Image Upload System in Strapi

To better understand this tutorial, we will build an image upload system that allows us to perform some image uploads and manipulations in Strapi.

Link from Gallery

Key Terms and Explanation

What is a REST API?

APIs are set of rules that allows different software applications to communicate with each other.

Representation State Transfer (REST) API, according to Wikipedia, is a software architectural style that was created to guide the design and development of the architecture for the World Wide Web.

In other words, with the REST API, we can make HTTP requests to servers to work on resources using the HTTP methods GET, POST, PUT, and DELETE.

What is Media Upload?

Media or file upload is very common in most technologies and providers, such as Nodejs, Laravel, Java, Cloudinary, AWS S3, etc., and is a fundamental aspect of web development. It is the transfer of files or media from a client machine to the server. This process is also a part of Strapi.

Strapi Media Library and the Upload Plugin

The Strapi Media Library allows the display of assets or files or via the media field of a Collection type. This plugin is activated by default.

On the other hand, the Upload plugin is responsible for powering the Media Library. You can use the Upload plugin API to upload files to Strapi or use the Media Library directly from the Strapi admin dashboard.

With the upload plugin, you can choose to upload files to the following:

Strapi maintains all the providers above. In the Strapi market place, you can find other providers such as Cloudimage, tools or Strapi plugins for image manipulation like Placeholder, Responsive image, Local Image Sharp, Image Color Palette, and so on. You can check out the Strapi market place here.

Strapi Media Library interface.png
Strapi Media Library interface

Features of Strapi Media Library

  • Extensive file format support
  • Ability to search, sort, and filter files
  • Preview of files
  • Storage service integrations
  • Automatic size optimization
  • Live Editing
  • Single or multiple uploads, etc.

To learn more, you can always visit the Strapi docs.

Types of Media in Strapi

Strapi supports various media uploads, including audio, files, images, and videos, as shown in the image below.

Types of Media in Strapi.png
Types of Media in Strapi

For this tutorial, we will only examine Image Uploads. However, the methods discussed here should work for any other media type.

✋ NOTE:
Strapi allows you to upload different kinds of files for uploading, as we will see shortly. However, we will use images for this tutorial.

Configuring the Strapi Upload Plugin

Strapi also allows us to configure the upload provider. This means that we can configure the file size, resolution, upload request timeout, responsive image size, and so on. With this, developers can have the freedom to customize and configure media uploads.

You can find information about configuring the Strapi Upload plugin here.

How to Upload Images in Strapi

There are different ways we can upload images in Strapi. They include:

  • Upload Single/Multiple images in Strapi.
  • Link an image by uploading it to an existing entry in Strapi.
  • Link an uploaded image by its ID to an Existing Entry in Strapi.
  • Upload upon entry creation (deprecated in Strapi 5; we will use another method).
  • Upload a single file from an API controller in Strapi

We will see all these in action in the next sections.

Create a Strapi Application

Install Strapi v5

To install [Strapi 5](https://strapi.io/five, run the command below:

npx create-strapi@latest
Enter fullscreen mode Exit fullscreen mode

Or via Yarn.

yarn create strapi
Enter fullscreen mode Exit fullscreen mode

We will give it the name strapi-backend. Please follow the prompts and answers below:

? What is the name of your project? strapi-backend

We can't find any auth credentials in your Strapi config.

Create a free account on Strapi Cloud and benefit from:

- ✦ Blazing-fast ✦ deployment for your projects
- ✦ Exclusive ✦ access to resources to make your project successful
- An ✦ Awesome ✦ community and full enjoyment of Strapi's ecosystem

Start your 14-day free trial now!


? Please log in or sign up. Skip
? Do you want to use the default database (sqlite) ? Yes
? Start with an example structure & data? No
? Start with Typescript? Yes
? Install dependencies with npm? Yes
? Initialize a git repository? No
Enter fullscreen mode Exit fullscreen mode

Start Strapi Application

If the installation is successful, CD into the Strapi project and run the command below to start the Strapi application.

npm run develop
Enter fullscreen mode Exit fullscreen mode

Create Food Collection and Fields

We will first create our Food Collection Type.

Navigate to Content-Type Builder > Create a new collection type and enter the display name as Food.

Create Food Collection.png
Create Food Collection

Once you have entered the display name, click on Continue to create the following fields.

  • name: This is the name of any food entry. It should be of type Short text.
  • cover: This will represent the image of any food. It should be of type Media (Single media). Ensure that you click the ADVANCED SETTINGS tab and select images as the only allowed type of media.

Select Image as the only Allowed Media.png
Select Image as the only Allowed Media

Click on the Save button. If successful, this is what the Food collection should look like:

The Food Collection Type.png
The Food Collection Type

Create Food Entries

The Food collection has been created, so let's add some entries. Create some entries with covers and also create some without covers, as shown below. For the latter without covers, we will add their covers by uploading them via the REST API using Next.js.

Create entries with and without covers.png
Create entries with and without covers

Enable Permission for Image Upload via API

We must first give permission to enable image upload via a REST API. To do this, navigate to Settings > Roles > Public, scroll down to the Upload plugin, and select the findOne, find and upload actions. Here is what they do:

  • find: This will allow us to fetch all images uploaded to the Strapi. This endpoint for this is /api/upload/files
  • findOne: This will allow us to fetch a specified image using its documentId. The endpoint for this is /api/upload/files/:id
  • upload: This will allow us to upload images to Strapi. The endpoint for this is /api/upload/
  • destroy: This will allow us to delete an image. The endpoint for this is /api/upload/files/:id.

!Enable API Permission for Food Collection.png
Enable Permission for Image Upload

To confirm this works, open the link http://localhost:1337/api/upload/files in your browser. You should see all the uploaded images and their data.

Fetch all images in Strapi backend.png
Fetch all images in Strapi backend

Enable API Permission for Food Collection

Enable API permissions to the Food collection, just like we did above. Click the Select all checkbox.

Enable Permission for Image Upload.png
Enable API Permission for Food Collection

Bravo! We now have access to upload images, view images, delete images, and to view a specific image. We also have access to the Food collection. Now, let's move on to the frontend to start image uploading.

Create a Next.js Application

Install Next.js

Install Next.js by running the command below:

npx create-next-app@latest
Enter fullscreen mode Exit fullscreen mode

Following the prompts after running the command above, we will name our project nextjs-frontend. See other prompts and answers below:

✔ What is your project named? nextjs-frontend
✔ Would you like to use TypeScript? Yes
✔ Would you like to use ESLint? No
✔ Would you like to use Tailwind CSS? Yes
✔ Would you like your code inside a `src/` directory? Yes
✔ Would you like to use App Router? (recommended) Yes
✔ Would you like to use Turbopack for next dev? No
✔ Would you like to customize the import alias (@/* by default)? No
Enter fullscreen mode Exit fullscreen mode

Start The Next.js App

After successfully installing Next.js, the next step is to start the application. Run the command below:

npm run dev 
Enter fullscreen mode Exit fullscreen mode

The command above will start our Next.js application on http://localhost:3000, as shown below.

Next.js Application.png
Next.js Application

Install Additional Dependencies

We need to install react-toastify. This will help us with toasts or notifications.

npm i react-toastify
Enter fullscreen mode Exit fullscreen mode

Ensure you CD into the directory of the Next.js app folder above, nextjs-frontend, before running the command above.

Create TypeScript Types

Inside the ./src/app folder, create a new file called Types.ts. This file will contain global types for our application, the ImageEntry and FoodEntry.

// ./src/app/Type.ts

// Image entry data types
export interface ImageEntry {
  id: number;
  documentId: string;
  name: string;
  caption: string;
  alternativeText: string;
  url: string;
}

// food entry data types
export interface FoodEntry {
  id: number;
  documentId: string;
  name: string;
  cover: ImageEntry;
}
Enter fullscreen mode Exit fullscreen mode

Create Next.js Components for our App

Before we begin creating the main functionalities of our application, let's create some starter components. Create a components folder inside the ./src folder. The components folder is where the components below will be created.

  1. Create the SubmitButton Component Start by creating a submit button component. Create a SubmitButton.tsx inside the ./src/components folder.
// ./src/components/SubmitButton.tsx

"use client";

import { useFormStatus } from "react-dom";

export default function SubmitButton({ title }: { title: string }) {
  const { pending } = useFormStatus();

  return (
    <button
      type="submit"
      aria-disabled={pending}
      className="bg-black text-sm w-[100px] text-white px-3 py-1 rounded-lg"
    >
      {pending ? `${title}ing...` : title}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component above renders a submit button that disables itself and shows "ing" (e.g., "Submitting...") during form submission, using the useFormStatus hook. The "use client"; directive ensures it's client-side in Next.js.

  1. Create MultipleOrSingleUpload Component This is where we will allow single or multiple image upload. Inside the ./src/components folder, create a MultipleOrSingleUpload.tsx file and add the following code.
// ./src/components/MultipleOrSingleUpload.tsx

"use client";

import SubmitButton from "./SubmitButton";

export default function MultipleOrSingleUpload() {

  return (
    <form className="flex rounded h-screen lg:w-full">
      <div className="divide-y w-full">
        <div className="w-full my-5">
          <p className=" text-base lg:text-lg">Upload Multiple Files</p>
          <span className="text-sm text-[#71717a]">
            Here, you can upload one or more files!
          </span>
        </div>
        <div className="flex flex-col pt-10 gap-y-7">
          <input
            type="file"
            name="files"
            className="text-sm text-[#71717a] p-5 lg:p-0  border"
            multiple
          />
          <SubmitButton title="Upload" />
        </div>
      </div>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component above renders a form for uploading single or multiple files. It has an input field with name as files, for selecting files and a submit button. It uses the SubmitButton component, which we created above, for handling form submissions.

  1. Create the LinkToSpecifiEntry Component This component will help us link an image to a specific entry by uploading it. Create a LinkToSpecificEntry.tsx file inside the ./src/components folder.
// ./src/components/LinkToSpecificEntry.tsx

"use client";

import { useState } from "react";

enum LinkType {
  UPLOAD_FILE = "file",
  GALLERY = "gallery",
}
export default function LinkToSpecificEntry() {
  const [linkType, setLinkType] = useState<string>(LinkType.UPLOAD_FILE);

  return (
    <div>
      <div className="w-full my-5">
        <p className="text-lg font-semibold">Link to a Food</p>
        <span className="text-sm text-[#71717a]">
          Link to a specific entry and add a cover (image)
        </span>
      </div>
      <div className="flex justify-between items-center w-full border">
        <button
          type="button"
          onClick={() => setLinkType(LinkType.UPLOAD_FILE)}
          className={`${
            linkType === LinkType.UPLOAD_FILE
              ? "bg-black text-white"
              : "bg-white text-black"
          } py-2 basis-1/2 px-3 transition-all duration-500`}
        >
          Link By Upload
        </button>
        <button
          type="button"
          onClick={() => setLinkType(LinkType.GALLERY)}
          className={`${
            linkType === LinkType.GALLERY
              ? "bg-black text-white"
              : "bg-white text-black"
          } py-2 basis-1/2 px-3 transition-all duration-500`}
        >
          Link from Gallery
        </button>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The component above allows users to link to a specific food entry by uploading an image or selecting from a gallery (images already existing in Strapi admin dashboard). It uses useState to toggle between two button options (upload or gallery), dynamically changing their appearance based on the selected link type. The appearance is the form for each button option, which we will see shortly.

  1. Create the LinkedImages Component This is where we will show food entries along with their linked images. Create a LinkedImages.tsx file inside the components folder and add the following code.
// ./src/components/LinkedImages.tsx

"use client";

import { useState, useEffect } from "react";
import { Food } from "../Types";

export default function LinkedImages() {
  const [foods, setFoods] = useState<Food[]>([]);

  const getFoods = async () => {};

  useEffect(() => {
    getFoods();
  }, []);

  return (
    <div className=" w-full">
      <div className="w-full my-5">
        <p className="text-lg font-semibold">Entries with Linked Images</p>
        <span className="text-sm text-[#71717a]">
          This is where you find all entries along with their linked images.
        </span>
      </div>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode

The component above displays a section for entries with linked images and uses useState to manage a list of foods. The getFoods function is triggered via useEffect when the component mounts to fetch and set food data. Inside the getFoods function, we will invoke a service, which we will create shortly, to fetch food entries from the Strapi backend.

  1. Create UploadAtEntryCreation Component The next component, the UploadAtEntryCreation, will allow us to upload an image upon entry creation. Although this is no longer possible in Strapi v5, we will achieve it using two steps.

🤚 NOTE
In Strapi 5, creating an entry while uploading a file is no longer possible. The recommended steps are done in two steps, which we will see soon.

// ./src/components/UploadAtEntryCreation.tsx

"use client";

import SubmitButton from "./SubmitButton";

export default function UploadAtEntryCreation() {
  return (
    <form className="flex rounded h-screen lg:w-full">
      <div className="w-full">
        <div className="w-full my-5">
          <p className=" text-base lg:text-lg font-semibold">
            Upload a File at Entry Creation
          </p>
          <span className="text-sm text-[#71717a]">
            Here, you can create an entry with an image!
          </span>
        </div>
        <div className="flex flex-col pt-10 space-y-2">
          <label htmlFor="cover">Food Name</label>
          <input
            type="text"
            name="name"
            placeholder="Name"
            className="text-sm text-[#71717a] p-2 border"
          />
          <span className="text-sm text-[#71717a]">
            Select the image for this entry!
          </span>
        </div>
        <div className="flex flex-col pt-10 gap-y-7">
          <span className="flex flex-col space-y-2">
            <label htmlFor="cover">Cover</label>
            <input
              type="file"
              name="files"
              className="text-sm text-[#71717a] p-5 lg:p-0  border"
            />
            <span className="text-sm text-[#71717a]">
              Here, you can upload two or more files!
            </span>
          </span>
          <SubmitButton title="Upload" />
        </div>
      </div>
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

The UploadAtEntryCreation component above renders a form that allows users to create an entry with an image upload during entry creation. It includes fields for the entry name and an option to upload one or more files. The SubmitButton component is imported and handles form submission.

  1. Create Gallery Component This is where all uploaded images in the Strapi backend will be displayed. Create a Gallery.tsx file inside the components folder and add the following code.
// ./src/components/Gallery.tsx

"use client";

import { useEffect, useState } from "react";
import { ImageI } from "@/app/Types";

export default function Gallery() {
  const [images, setImages] = useState<ImageI[]>([]); // state to hold images
  const [selectedImage, setSelectedImage] = useState<ImageI | null>(null); // for selected image
  const [update, setUpdate] = useState<boolean> (false); // if image should be updated or not

  // call service to fetch images
  const handleFetchImages = async () => {};

  // function to close the update modal
  const closeModal = () => {
    setUpdate(false);
  };

  // function to delete an image using the delete service
  const onDeleteImage = async (imageId: number) => {};

  // fetch images upon mounting
  useEffect(() => {
    handleFetchImages();
  }, []);

  return (
    <div className=" w-full divide-y">
      <div className="w-full my-5">
        <p className="text-lg font-semibold">Welcome to Gallery</p>
        <span className="text-sm text-[#71717a]">
          This is where all uploaded images can be found.
        </span>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The component above renders a gallery for managing uploaded images. It uses useState to handle images, selected images, and a modal state for updates. The handleFetchImages function is called inside useEffect when the component mounts to fetch the images. Functions for closing the modal and deleting an image are also defined but not implemented.

  1. Create the TabsContainer Component This is where we will toggle between tabs to display each component created above. Create a TabsContainer.tsx file inside the components folder.
// .src/components/TabsContainer.tsx

"use client";

import { ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

import MultipleOrSingleUpload from "./MultipleOrSingleUpload";

import { useState } from "react";
import LinkToSpecificEntry from "./LinkToSpecificEntry";
import LinkedImages from "./LinkedImages";
import Gallery from "./Gallery";
import UploadAtEntryCreation from "./UploadAtEntryCreation";

// tabs for image uploads and manipulations
enum Tabs {
  MULTIPLE_OR_SINGLE = "multipleOrSingle",
  SPECIFIC_ENTRY = "specificEntry",
  LINKED_IMAGES = "linkedImages",
  ENTRY_CREATION = "entryCreation",
  GALLERY = "gallery",
}

export default function TabsContainer() {
  const [tab, setTab] = useState<string>(Tabs.MULTIPLE_OR_SINGLE);

  return (
    <div>
      <ToastContainer />

      <div className="flex flex-col lg:flex-row w-full h-full lg:p-10 gap-x-10 ">
        <div className="lg:flex  lg:flex-col text-sm items-start h-screen basis-2/6 w-full pt-5 ">
          <button
            className={`${
              tab == Tabs.MULTIPLE_OR_SINGLE
                ? " px-5 lg:px-4 text-red-500 lg:text-inherit  bg-[#f4f4f5] "
                : null
            } px-4 py-2 rounded-lg text-left  w-full`}
            onClick={() => {
              setTab(Tabs.MULTIPLE_OR_SINGLE);
            }}
          >
            Upload Multiple Files
          </button>
          <button
            className={`${
              tab == Tabs.SPECIFIC_ENTRY
                ? " px-5 lg:px-4 text-red-500 lg:text-inherit  bg-[#f4f4f5] "
                : null
            } px-4 py-2 rounded-lg text-left  w-full`}
            onClick={() => {
              setTab(Tabs.SPECIFIC_ENTRY);
            }}
          >
            Link to a Specific Entry
          </button>
          <button
            className={`${
              tab == Tabs.LINKED_IMAGES
                ? " px-5 lg:px-4 text-red-500 lg:text-inherit  bg-[#f4f4f5] "
                : null
            } px-4 py-2 rounded-lg text-left  w-full`}
            onClick={() => {
              setTab(Tabs.LINKED_IMAGES);
            }}
          >
            Linked Images
          </button>
          <button
            className={`${
              tab == Tabs.ENTRY_CREATION
                ? " px-5 lg:px-4 text-red-500 lg:text-inherit  bg-[#f4f4f5] "
                : null
            } px-4 py-2 rounded-lg text-left  w-full`}
            onClick={() => {
              setTab(Tabs.ENTRY_CREATION);
            }}
          >
            Upload at Entry creation
          </button>
          <button
            className={`${
              tab == Tabs.GALLERY
                ? " px-5 lg:px-4 text-red-500 lg:text-inherit  bg-[#f4f4f5] "
                : null
            } px-4 py-2 rounded-lg text-left  w-full`}
            onClick={() => {
              setTab(Tabs.GALLERY);
            }}
          >
            Gallery
          </button>
        </div>

        <div className="h-screen w-full flex flex-col  gap-y-10 basis-4/6">
          {tab === Tabs.MULTIPLE_OR_SINGLE ? (
            <MultipleOrSingleUpload />
          ) : tab === Tabs.SPECIFIC_ENTRY ? (
            <LinkToSpecificEntry />
          ) : tab === Tabs.LINKED_IMAGES ? (
            <LinkedImages />
          ) : tab === Tabs.ENTRY_CREATION ? (
            <UploadAtEntryCreation />
          ) : (
            <Gallery />
          )}
        </div>
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The TabsContainer component above renders a tabbed interface for managing various upload and image-related tasks we have mentioned previously, using useState to control which tab is active. The component includes buttons for selecting tabs, such as "Upload Multiple Files" and "Gallery," and conditionally renders the corresponding content based on the selected tab. It also integrates the ToastContainer from react-toastify to display toasts or notifications.

Display All Created Components

We want all the tab components created above to be visible. Here are the steps we will take to achieve this:

  • Step 1: Remove Default CSS Ensure you visit the ./src/app/global.css file and update it with the following code:
@tailwind base;
@tailwind components;
@tailwind utilities;
Enter fullscreen mode Exit fullscreen mode

By updating the above, we have removed any custom default CSS.

  • Step 2: Render TabsContainer Component Update the code in the ./src/page.tsx file with the following:
// ./src/app/page.tsx

import TabsContainer from "./components/TabsContainer";

export default function Home() {
  return (
    <div className="min-h-screen p-3 lg:p-20">
      <div className=" lg:px-14 py-5">
        <p className="text-2xl lg:text-4xl font-bold mb-4">
          Image Upload to Strapi
        </p>
        <span className="text-slate-400">
          Let's demonstrate image upload to Strapi Content types using the REST
          API
        </span>
        <TabsContainer />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

This is what our app should look like

Tab Interface for our Application.gif
Tabs for our application

Create Server Actions for our App

The next step is to create Next.js server actions. These are asynchronous functions that are executed on the server.

With server actions, you can ensure that heavy computations and sensitive operations are performed securely on the server, reducing the load on the client.

Here are the server actions we will create:

  1. Multiple or single upload server action: This will allow us to upload a single or multiple images to Strapi.
  2. Link to a specific entry server action (link an already uploaded image and link by uploading an image)
  3. Upload at entry creation server action
  4. Update image server action

Before we proceed to do that, let's create some constants and default actions. Inside the ./src/app folder, create a file called actions.tsx and add the following code:

"use server";

const STRAPI_URL: string = "http://localhost:1337";

// accepted image types
const ACCEPTED_IMAGE_TYPES = [
  "image/jpeg",
  "image/jpg",
  "image/png",
  "image/webp",
];

// upload single or multiple images
export async function uploadMultipleOrSingleAction(
  prevState: any,
  formData: FormData
) {}

// Upload an Image and link to an entry
export async function LinkByUploadAction(
  prevState: any,
  formData: FormData
) {}

// Link an Uploaded image to an Entry by ID
export async function linkFromGalleryAction(
  prevState: any,
  formData: FormData
) {}

// Upload image at entry creation
export async function uploadAtEntryCreationAction(
  prevState: any,
  formData: FormData
){}

// update mage file info action
export async function updateImageAction(
  prevState: any, 
  formData: FormData
){}
Enter fullscreen mode Exit fullscreen mode

In the code above, STRAPI_URL stores the base URL of the Strapi API, and ACCEPTED_IMAGE_TYPES is an array that lists the accepted image formats (JPEG, JPG, PNG, and WEBP) for file uploads. The "use server" directive ensures the code runs on the server side. We then created the server actions that our Next.js server will perform, which we will later modify.

🤚 NOTE
We will update the server actions above later in this tutorial.

Create Services for Our App

Next, we will create services for our application. Services are reusable functions that communicate with other services or external systems and can be called anytime.

For this application, we will create 3 services:

  1. Fetch Images Service: This will help fetch images from the Strapi backend.
  2. Fetch Foods Service: This will help fetch food entries from the Strapi backend.
  3. Delete image service: This will help delete an image from the Strapi backend.

Proceed to create a file called services.ts and add the following code.

// ./src/app/services.ts


const STRAPI_URL: string = "http://localhost:1337";

export const fetchImages = async () => {
  try {
    // fetch images from Strapi backend
    const response = await fetch(`${STRAPI_URL}/api/upload/files`);

    // if response is not ok
    if (!response.ok) {
      const errorDetails = await response.text();
      throw new Error(
        `Error fetching images: ${response.status} ${response.statusText} - ${errorDetails}`
      );
    }

    // return fetched images
    const result = await response.json();
    return result;
  } catch (error: any) {
    throw new Error(`Failed to fetch images: ${error.message}`);
  }
};

export const fetchFoods = async () => {
  try {
    // fetch foods from Strapi backend
    const response = await fetch(`${STRAPI_URL}/api/foods?populate=*`);

    // if response is not ok
    if (!response.ok) {
      const errorDetails = await response.text();
      throw new Error(
        `Error fetching Foods: ${response.status} ${response.statusText} - ${errorDetails}`
      );
    }

    // return fetched foods
    const result = await response.json();
    return result;
  } catch (error: any) {
    // throw new error
    throw new Error(`Failed to fetch images: ${error.message}`);
  }
};

export const deleteImage = async (imageId: number) => {
  try {
    // make a DELETE request using image id.
    const response = await fetch(`${STRAPI_URL}/api/upload/files/${imageId}`, {
      method: "DELETE",
    });

    // if response is not ok
    if (!response.ok) {
      const errorDetails = await response.text();
      throw new Error(
        `Error deleting food entry: ${response.status} ${response.statusText} - ${errorDetails}`
      );
    }
  } catch (error: any) {
    // throw new error
    throw new Error(`Failed to delete entry: ${error.message}`);
  }
};
Enter fullscreen mode Exit fullscreen mode

Here is what the following services above do:

  • fetchImages: Retrieves all uploaded images from the Strapi backend by sending a GET request to the /api/upload/files endpoint. It processes the response, throwing errors if the request is unsuccessful, and returns the list of images in JSON format.
  • fetchFoods: Retrieves all food entries from the Strapi backend, including any populated relationships, by sending a GET request to the /api/foods endpoint with a populate=* query parameter. It checks for errors, handles unsuccessful responses, and returns the data in JSON format.
  • deleteImage: Deletes a specific image from the Strapi backend by sending a DELETE request to /upload/files/{imageId} using the image’s unique ID. If the deletion fails, it throws a custom error message.

Now that we have created the services let's implement them.

Upload Single or Multiple Images to Strapi

Now, let's modify the MultipleOrSingleUpload component and the uploadMultipleOrSingleAction to implement this.

We will do this in 2 steps:

1. Update the uploadMultipleOrSingleAction Server Action

Update the uploadMultipleOrSingleAction server action so as to invoke it in the MultipleOrSingleImageUpload component.

// ./src/actions.ts
...
export async function uploadMultipleOrSingleAction(
  prevState: any,
  formData: FormData
) {
  try {
    const response = await fetch(`${STRAPI_URL}/api/upload`, {
      method: "post",
      body: formData,
    });

    const result = await response.json();

    if (result.error) {
      return {
        uploadError: result.error.message,
        uploadSuccess: null,
      };
    }

    return {
      uploadError: null,
      uploadSuccess: "Images uploaded successfully",
    };
  } catch (error: any) {
    return {
      uploadError: error.message,
      uploadSuccess: null,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

Here is what the code above does:

  • prevState: This is the previous state of the form submission, which contains properties uploadError and uploadSuccess. It's provided when useFormState is invoked.
  • formData: This is an instance of the FormData API containing the actual form data that the user wants to upload, which in this case are images.
  • The function sends a POST request to the Strapi endpoint /api/upload to upload images. The formData, from the upload, is passed as the body of the request. If there's an error in the response (like the upload failing), it returns an error message. If the upload is successful, it returns a success message.

2. Update the MultipleOrSingleUpload Component

Let's modify the MultipleOrSingleUpload component form to trigger the server action above.

// .src/components/MultipleOrSingleImageUpload

"use client";
import { uploadMultipleOrSingleAction } from "@/app/actions";
import { useFormState } from "react-dom";
import { Ref, useRef } from "react";
import SubmitButton from "./SubmitButton";
import { toast } from "react-toastify";

const initialState = {
  uploadError: null,
  uploadSuccess: null,
};

export default function MultipleOrSingleImageUpload() {
  const [state, formAction] = useFormState(
    uploadMultipleOrSingleAction,
    initialState
  );
  const formRef = useRef<HTMLFormElement | null>(null);

  if (state?.uploadSuccess) {
    formRef?.current?.reset();
    toast.success(state?.uploadSuccess);
  }

  return (
    <form
      ref={formRef}
      action={formAction}
      className="flex rounded h-screen lg:w-full"
    >
      <div className="divide-y w-full">
        <div className="w-full my-5">
          <p className=" text-base lg:text-lg">Upload Multiple Files</p>
          <span className="text-sm text-[#71717a]">
            Here, you can upload two or more files!
          </span>
        </div>
        <div className="flex flex-col pt-10 gap-y-7">
          <input
            type="file"
            name="files"
            className="text-sm text-[#71717a] p-5 lg:p-0  border"
            multiple
          />
          {state?.uploadError ? (
            <span className="text-red-500">{state?.uploadError}</span>
          ) : null}
          <SubmitButton title="Upload" />
        </div>
      </div>
    </form>
  );
}

Enter fullscreen mode Exit fullscreen mode

Here is what we did in the code above:

  • state: This object is created by useFormState, which tracks the state of the upload (whether there’s an error or success).
  • The initial state, initialState, includes uploadError: null and uploadSuccess: null. They will be used to pass error and success messages between the uploadMultipleOrSingleAction server action and the form above.
  • formAction: This function links to the uploadMultipleOrSingleAction server action and is triggered when the form is submitted.
  • formRef: This is a reference to the form DOM element, created using useRef. It’s used to reset the form fields after a successful upload.
  • The <input> element has the multiple attribute, which allows users to select more than one file at a time. The name="files" attribute is crucial because it matches the expected key when submitting files in the FormData.
  • If state.uploadError contains a message, it will be displayed in red below the input field using a conditional rendering block.
  • If the state.uploadSuccess contains a success message (indicating that the files were uploaded successfully), the form is reset by calling formRef.current.reset().
  • Additionally, a toast notification is displayed using toast.success(state.uploadSuccess) to notify the user that the upload was successful.

When everything is set and done, we should be able to upload a single or multiple images to our Strapi backend. See the GIF below:

Single or Multiple Image upload
Single or Multiple Image upload

When we visit the Strapi backend, we should see the images we just uploaded.

Images in Strapi Backend.png
Images in Strapi Backend

Link an Image to an Existing Entry in Strapi

Like we pointed out in the beginning, we will do this in two ways. One is to link to an already existing image in Strapi backend by its ID to an entry and the other is to upload an image and link to a specific food entry.

Method 1: Link an Image to an Entry in Strapi

When we click on the "Link to a Specific Entry" tab, we can see "Link by Upload" and "Link from Gallery". In this method 1, we will link by upload.

  • Step 1: Update the LinkByUploadAction Server Action
// ./src/actions.ts

...
export async function LinkByUploadAction(prevState: any, formData: FormData) {
  try {
    // Convert formData into an object to extract data
    const data = Object.fromEntries(formData);

    // Create a new FormData object to send to the server
    const formDataToSend = new FormData();
    formDataToSend.append("files", data.files); // The image file
    formDataToSend.append("ref", data.ref); // The reference type for Food collection
    formDataToSend.append("refId", data.refId); // The ID of the food entry
    formDataToSend.append("field", data.field); // The specific field to which the image is linked, i.e., "cover"

    // Make the API request to Strapi to upload the file and link it to the specific entry
    const response = await fetch(`${STRAPI_URL}/api/upload`, {
      method: "post",
      body: formDataToSend,
    });

    // upload respone
    const result = await response.json();

    // Handle potential errors from the API response
    if (result.error) {
      return {
        uploadError: result.error.message,
        uploadSuccess: null,
      };
    }

    // Return success if the upload and linking are successful
    return {
      uploadSuccess: "Image linked to a food successfully!",
      uploadError: null,
    };
  } catch (error: any) {
    // Catch any errors that occur during the process
    return {
      uploadError: error.message,
      uploadSuccess: null,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, prevState holds the previous state of the upload process. formData contains the form data submitted by the user, including the file and other necessary fields (files, ref, refId, and field). The ref represents the reference type for Food collection, which we will add as a hidden value in the form below. The refId represents the ID of the food entry. Then we have the field, which represents the cover field of the food entry that we want to link.

  • Step 2: Create a Form for Linking by Upload We have to trigger the server action above. To do this, inside the ./src/components folder, create a folder called form. Inside the new form folder, create a file called LinkByUpload.tsx and add the following code:
// ./src/components/LinkByUpload.tsx

import { useEffect, useState } from "react";
import { useFormState } from "react-dom";
import { toast } from "react-toastify";
import { LinkByUploadAction } from "@/app/actions";

import SubmitButton from "@/components/SubmitButton";
import { fetchFoods } from "@/app/services";
import { FoodEntry } from "@/app/Types";

// initial state
const initialState = {
  uploadError: null,
  uploadSuccess: null,
};

export default function LinkByUpload() {
  const [state, formAction] = useFormState(LinkByUploadAction, initialState);
  const [foods, setFoods] = useState<FoodEntry[]>([]);

  const handleFetchFoods = async () => {
    const result = await fetchFoods();
    setFoods(result?.data);
  };

  useEffect(() => {
    handleFetchFoods();
  }, []);

  if (state?.uploadSuccess) {
    toast.success(state?.uploadSuccess);
  }

  return (
    <div className="w-full">
      <form action={formAction} className="flex rounded w-full">
        <div className="flex flex-col pt-10 gap-y-7 w-full">
          <div className="flex flex-col space-y-2">
            <label htmlFor="name">Food</label>
            <select name="refId" className="border p-2 text-[#71717a] text-sm">
              {foods.map((food) => {
                return (
                  <option key={food.id} value={food.id}>
                    {" "}
                    {food.name}
                  </option>
                );
              })}
            </select>
            <span className="text-sm text-[#71717a]">
              Select the food you want to add Image to
            </span>
            <div className="flex flex-col space-y-2 pt-10">
              <label htmlFor="cover">Cover</label>
              <input
                type="file"
                name="files"
                className="text-sm text-[#71717a] border"
              />
              <span className="text-sm text-[#71717a]">
                Select an image to link to a food
              </span>
            </div>

            {state?.uploadError ? (
              <span className="text-red-500">{state?.uploadError}</span>
            ) : null}
            <input type="hidden" name="ref" value="api::food.food" />
            <input type="hidden" name="field" value="cover" />
            <div className="pt-5">
              <SubmitButton title="Link" />
            </div>
          </div>
        </div>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

From the code above, we have the foods state that stores the list of food items fetched from the server, which will be displayed in the dropdown menu. In other words, the component fetches food items using fetchFoods and populates the dropdown <select> menu. With that, users can select a food item, upload an image, and submit the form. Note that we have some hidden values such as ref. This has a value of api::food.food, which is the reference type for the food collection. It also has the refId as the id value of any food selected. The form triggers the LinkByUploadAction server action to perform the actual file upload and linking.

  • Step 3: Update the LinkToSpecificEntry component Now, we will have to update the LinkToSpecificEntry component to include the LinkByUpload form we created above. Head over to ./src/components/LinkToSpecificEntry.tsx and replace the content with the following code:
// ./src/components/LinkToSpecificEntry.tsx

"use client";

import { useState } from "react";

import LinkByUpload from "./form/LinkByUpload";

// linking type
enum LinkType {
  UPLOAD = "upload",
  GALLERY = "gallery",
}

export default function LinkToSpecificEntry() {
  const [linkType, setLinkType] = useState<LinkType>(LinkType.UPLOAD);

  return (
    <div>
      <div className="w-full my-5">
        <p className="text-lg font-semibold">Link to a Food</p>
        <span className="text-sm text-[#71717a]">
          Link to a specific entry and add a cover (image)
        </span>
      </div>
      <div className="flex justify-between items-center w-full border">
        <button
          type="button"
          onClick={() => setLinkType(LinkType.UPLOAD)}
          className={`${
            linkType === LinkType.UPLOAD
              ? "bg-black text-white"
              : "bg-white text-black"
          } py-2 basis-1/2 px-3 transition-all duration-500`}
        >
          Link By Upload
        </button>
        <button
          type="button"
          onClick={() => setLinkType(LinkType.GALLERY)}
          className={`${
            linkType === LinkType.GALLERY
              ? "bg-black text-white"
              : "bg-white text-black"
          } py-2 basis-1/2 px-3 transition-all duration-500`}
        >
          Link from Gallery
        </button>
      </div>
      {linkType === LinkType.UPLOAD ? <LinkByUpload /> : null }
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the code above, the LinkToSpecificEntry component creates an interface where users can either upload a new image or (in the future, which we will see soon) choose one from a gallery to link to a specific food entry. The form interaction is handled through state changes and conditional rendering, with the actual file upload functionality delegated to the LinkByUpload component.

Now, let's test our code:

Link By Upload
Link an image by uploading to an entry

Head over to the Strapi backend to see if this was successful.

Linked Image not Showing.png
Linked Image not Showing

As shown in the image below, the linked image for "Pizza" didn't show. Now click on the Pizza entry and click on the "Published" tab. There, you will find the linked image.

Linked Image Shows only on Published Tab or version.png
Linked Image Shows only on Published Tab or verion

This means that the entry was published directly after linking. This happens as a result of the Draft and Publish feature of Strapi. But we don't want this behaviour. So, let's disable Draft and Publish in Food collection.

  • Disable Draft and Publish for Food Collection Click on the Content-Type Builder and click on the Edit button for Food collection. Once the modal opens up, click on the ADVANCED SETTINGS tab and disable Draft and Publish. Ensure that you also click the Finish button and wait for the server to restart.

Disable Draft & publish.png
Disable Draft & publish

Once that is done, we can now see the linked image for Pizza!

Linked Image is now displayed.png
Linked Image is now displayed

We can now proceed to link an already uploaded image to an existing entry.

Method 2: Link an Already Uploaded Image to an Existing Entry in Strapi

The next method would be to link an already uploaded image in the Strapi backend. In other words, we want to link using an image ID. To do this, we have to fetch images in the Strapi backend, thanks to the fetchImages service.

  • Step 1: Configure Next.js For Image Links Since we will be displaying images uploaded to the Strapi backend, which is an external source, we need to protect our application from malicious users; configuration is required in order to use external images. Locate the ./next.config.mjs file and replace the content with the following code:
/** @type {import('next').NextConfig} */
const nextConfig = {
    images: {
        remotePatterns: [
            {
                hostname: "localhost"
            }
        ]
    }
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

With this, we have configured localhost to be a secure link to our images.

  • Step 2: Update LinkFromGallery Server Action Update the LinkFromGalleryAction action with the following code:
// ./src/app/actions.ts

...
export async function linkFromGalleryAction(
  prevState: any,
  formData: FormData
) {
  try {
    const data = Object.fromEntries(formData);

    const response = await fetch(`${STRAPI_URL}/api/foods/${data?.refId}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        data: {
          cover: data?.imageId,
        },
      }),
    });

    const result = await response.json();

    if (result.error) {
      return {
        uploadError: result.error.message,
        uploadSuccess: null,
      };
    }
    return {
      uploadSuccess: "Image linked to a food successfully!",
      uploadError: null,
    };
  } catch (error: any) {
    return {
      uploadError: error.message,
      uploadSuccess: null,
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

In the code above, we updated the LinkFromGallery server action. It takes parameters prevState that hold the previous state of the form. formData contains the form data submitted by the user, including the refId (food ID) and imageId (selected image ID).

The Object.fromEntries(formData) converts the form data from the FormData format into a standard object for easier manipulation. The API request is sent to STRAPI_URL/api/foods/${data?.refId} to update the specific food entry with the selected image.

The body of the request contains the new image's ID (imageId) to be set and linked as the food's cover image.

  • Step 3: Create a Form to Link by Gallery Inside the ./src/components/form folder, create another file called LinkByGallery.tsx and add the following code:
// ./src/components/form/LinkByGallery.tsx

import { useEffect, useState, useRef } from "react";
import { useFormState } from "react-dom";
import Image from "next/image";
import { fetchFoods, fetchImages } from "@/app/services";
import { toast } from "react-toastify";
import SubmitButton from "@/components/SubmitButton";
import { linkFromGalleryAction } from "@/app/actions";
import { FoodEntry, ImageEntry } from "@/app/Types";

const STRAPI_URL: string = "http://localhost:1337";

const initialState = {
  uploadError: null,
  uploadSuccess: null,
};
export default function LinkByGallery() {
  const [state, formAction] = useFormState(linkFromGalleryAction, initialState);
  const [foods, setFoods] = useState<FoodEntry[]>([]);
  const [images, setImages] = useState<ImageEntry[]>([]);
  const [selectedImageId, setSelectedImageId] = useState<number | string>("");

  const formRef = useRef<HTMLFormElement | null>(null);

  const handleFetchFoods = async () => {
    const result = await fetchFoods();
    setFoods(result?.data);
  };

  const handleFetchImages = async () => {
    const images = await fetchImages();
    setImages(images);
  };

  if (state?.uploadSuccess) {
    formRef?.current?.reset();
    toast.success(state?.uploadSuccess);
    state.uploadSuccess = "";
  }

  useEffect(() => {
    handleFetchFoods();
    handleFetchImages();
  }, []);
  return (
    <div className="w-full">
      <form ref={formRef} action={formAction} className="flex rounded w-full">
        <div className="w-full">
          <div className="flex flex-col pt-10 gap-y-7">
            <div className="flex flex-col space-y-2">
              <label htmlFor="name">Food</label>
              <select
                name="refId"
                className="border p-2 text-[#71717a] text-sm w-full"
                id="food"
              >
                {foods.map((food) => {
                  return <option value={food.documentId}>{food.name}</option>;
                })}
              </select>
              <span className="text-sm text-[#71717a]">
                Select the food you want to add Image to
              </span>
            </div>
          </div>
          {images?.length > 0 ? (
            <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 w-full pt-10">
              {images?.map((image) => (
                <div
                  className={`${
                    selectedImageId === image?.id ? "border-2 border-black" : ""
                  } h-[200px] w-[400]px group relative`}
                  onClick={() => setSelectedImageId(image?.id)}
                >
                  {selectedImageId === image?.id && (
                    <div className="flex items-center justify-center absolute top-0 left-0 w-full h-full">
                      <span className="absolute z-50 text-white font-extrabold">
                        selected
                      </span>
                    </div>
                  )}
                  <Image
                    src={`${STRAPI_URL}${image?.url}`}
                    alt={image.name}
                    width={400}
                    height={100}
                    className={` ${
                      selectedImageId === image?.id ? " opacity-50 " : " "
                    } transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50`}
                  />
                </div>
              ))}
            </div>
          ) : (
            <p className="w-full text-orange-300 pt-5">No Images in Gallery.</p>
          )}
          <p className="pt-2">
            {state?.uploadError ? (
              <span className="text-red-500">{state?.uploadError}</span>
            ) : null}
          </p>
          <input type="hidden" name="imageId" value={selectedImageId} />
          <div className="pt-5">
            <SubmitButton title="Link" />
          </div>
        </div>
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

In the LinkByGallery form component above, states foods are created to store the list of available foods fetched from the server, and images are created to store the list of images in the gallery. And selectedImageId, which will store the ID of the image selected by the user from the gallery.

We fetched data using the handleFetchFoods() function which fetches the list of available food entries from the server using fetchFoods() service. And the handleFetchImages() which fetches the list of images from the gallery using `fetchImages() service.

When an image is clicked, its imageId is stored in selectedImageId. The selected image is visually highlighted by applying a border to its container. The form is connected to useFormState(linkFromGalleryAction), which ties the form submission to the linkFromGalleryAction server action.

A hidden field named imageId is used to store the ID of the selected image, which is then sent to the server when the form is submitted.

After submission, if the operation is successful, the form is reset, and a success message is displayed using toast.success().
If an error occurs, the error message is displayed below the form.

  • Step 4: Import LinkByGallery to the LinkToSpecificEntry component.

`js
// .src/components/LinkToSpecificEntry.tsx

"use client";

import { useState } from "react";

import LinkByUpload from "./form/LinkByUpload";
import LinkByGallery from "./form/LinkByGallery";

// linking type
enum LinkType {
UPLOAD = "upload",
GALLERY = "gallery",
}

export default function LinkToSpecificEntry() {
const [linkType, setLinkType] = useState(LinkType.UPLOAD);

return (



Link to a Food



Link to a specific entry and add a cover (image)



type="button"
onClick={() => setLinkType(LinkType.UPLOAD)}
className={${
linkType === LinkType.UPLOAD
? "bg-black text-white"
: "bg-white text-black"
} py-2 basis-1/2 px-3 transition-all duration-500
}
>
Link By Upload

type="button"
onClick={() => setLinkType(LinkType.GALLERY)}
className={${
linkType === LinkType.GALLERY
? "bg-black text-white"
: "bg-white text-black"
} py-2 basis-1/2 px-3 transition-all duration-500
}
>
Link from Gallery


{linkType === LinkType.UPLOAD ? : }

);
}

`

Let's see this in action:

Link from Gallery
Link an image from gallery

Now, when we check the Strapi backend, we should see that the food entry and the image you selected have been linked.

Linked Image and Food Entry.png
Linked Image and Food Entry

With this method, we could link an already uploaded image in Strapi backend to a food entry. And we can also update or change the linked image of an entry.

In the next section, we will see how to upload a file at entry creation.

Display Linked Images and Food Entries

On the tabs section of our application, we can see that we have the "Linked Images" section. This should show us food entries along with their linked images.

Let's do this by updating the ./src/components/LinkedImages.tsx file.

`js
// ./src/components/LinkedImages.tsx

"use client";

import { useEffect, useState } from "react";
import Image from "next/image";
import { fetchFoods } from "@/app/services";
import { FoodEntry } from "@/app/Types";

const STRAPI_URL: string = "http://localhost:1337";

export default function LinkedImages() {
const [foods, setFoods] = useState([]); // foods state

// get foods using the featchFoods service
const getFoods = async () => {
const result = await fetchFoods();
setFoods(result?.data);
};

// fetch foods upon mounting
useEffect(() => {
getFoods();
}, []);

return (



Entries with Linked Images



This is where you find all uploaded images so as update anyone.


{foods?.length > 0 && (

{foods?.map((food) => (



{food?.name}



{food?.cover?.url ? (
src={`${STRAPI_URL}/${food?.cover?.url}`}
alt={food?.cover?.name}
width={200}
height={300}
className="transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50"
/>
) : (

No linked image

)}



))}

)}
{foods?.length <= 0 &&

No foods and images linked

}

);
}
`

This code above defines the LinkedImages component, which fetches and displays food entries from Strapi along with their linked images. The foods state stores the fetched data, which is updated by the getFoods function. The useEffect hook triggers getFoods when the component mounts to fetch the food data from the fetchFoods service. The component maps over the food array and displays each food’s name and image using the Image component. If a food has no linked image, it displays a "No linked image" message. The STRAPI_URL constant holds the Strapi server URL for image paths.

This is what the linked images and foods tab should look like:

Food Entries and Their Linked Images.png
Food Entries and Their Linked Images

As we can see, most of the food entries has been linked. Proceed to the "Link to a Specific" entry tab to link any food entry that has no cover or image linked to it.

Upload a file at Entry Creation

Since uploading a file at entry creation is no longer possible in Strapi 5, we will use the recommended programmatic two-step process. First, we have to upload the image to Strapi backend, and then create the entry with the created image ID.

Update the uploadAtEntryCreationAction Server Action

We will have to update the uploadAtEntryCreationAction server action with the following code:

`js
// ./src/actions.ts
...

export async function uploadAtEntryCreationAction(
prevState: any,
formData: FormData
) {
try {
const data = Object.fromEntries(formData);

// upload file
const uploadResponse = await fetch(`${STRAPI_URL}/api/upload`, {
  method: "post",
  body: formData,
});

// get result of upload
const uploadedImage = await uploadResponse.json();

// if error
if (uploadedImage.error) {
  return {
    uploadError: uploadedImage.error.message,
    uploadSuccess: null,
  };
}

// create entry
const newEntry = {
  data: {
    name: data.name,
    cover: uploadedImage[0]?.id,
  },
};

// Create entry API request
const response = await fetch(`${STRAPI_URL}/api/foods`, {
  method: "post",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(newEntry),
});

const result = await response.json();

if (result.error) {
  return {
    uploadError: result.error.message,
    uploadSuccess: null,
  };
}

return {
  uploadError: null,
  uploadSuccess: "Images uploaded successfully",
};
Enter fullscreen mode Exit fullscreen mode

} catch (error: any) {
return {
uploadError: null,
uploadSuccess: "Images uploaded successfully",
};
}
}
`

  • In the code above, we make a POST request to the Strapi API's upload endpoint (/api/upload) to upload the file contained in formData. The result is stored in uploadedImage. If the upload fails, it returns an error message.

  • Once the image is successfully uploaded, a new entry object (newEntry) is created, where the cover field references the uploaded image's ID.

  • To create the new entry, a POST request is sent to the /api/foods endpoint using the new entry data. If there is an error, it returns the error message. Otherwise, a success message is returned.

Update the UploadAtEntryCreation Component

In order to use the server action above, we have to modify the ./src/components/UploadAtEntryCreation.tsx file.

`js
// ./src/components/UploadAtEntryCreation.tsx

"use client";
import { uploadAtEntryCreationAction } from "@/app/actions";
import { useFormState } from "react-dom";
import { useEffect, useRef } from "react";
import SubmitButton from "./SubmitButton";
import { toast } from "react-toastify";

const initialState = {
uploadError: null,
uploadSuccess: null,
};

export default function UploadAtEntryCreation() {
const [state, formAction] = useFormState(
uploadAtEntryCreationAction,
initialState
);
const formRef = useRef(null);

useEffect(() => {
formRef?.current?.reset();
toast.success(state?.uploadSuccess);
}, [state]);

return (

  ref={formRef}<br>
  action={formAction}<br>
  className="flex rounded h-screen lg:w-full"<br>
&gt;<br>
  <br>
    <br>
      <p><br>
        Upload a File at Entry Creation<br>
      </p><br>
      <span><br>
        Here, you can create an entry with an image!<br>
      </span><br>
    <br>
    <br>
      Food Name<br>

        type="text"<br>
        name="name"<br>
        placeholder="Name"<br>
        className="text-sm text-[#71717a] p-2 border"<br>
      /&gt;<br>
      <span><br>
        Select the image for this entry!<br>
      </span><br>
    <br>
    <br>
      <span><br>
        Cover<br>

          type="file"<br>
          name="files"<br>
          className="text-sm text-[#71717a] p-5 lg:p-0  border"<br>
        /&gt;<br>
        <span><br>
          Here, you can upload two or more files!<br>
        </span><br>
      </span><br>
      {state?.uploadError ? (<br>
        <span>{state?.uploadError}</span><br>
      ) : null}<br>
      <br>
    <br>
  <br>
<br>
Enter fullscreen mode Exit fullscreen mode

);

}

`

  • The code above uses the useFormState hook to manage form state and handle submission via the uploadAtEntryCreationAction server action. The initialState initializes uploadError and uploadSuccess to null.
  • useEffect and useRef: The formRef references the form to reset it after a successful upload. The useEffect hook triggers this reset when state.uploadSuccess changes, and displays a success toast.
  • Render: The form collects a "Food Name" using the input name as name and an image file using the input type as file and name as files. When the form is submitted, it triggers the uploadAtEntryCreationAction server action to handle the file upload and entry creation process.

  • And the SubmitButton button component handles the form submission.

This is what we should see when we perform this operation:

View, Delete, and Update Images

Now, we want to be able to click on the "Gallery" tab and see all the images that have been uploaded to our Strapi backend.

Here is what we want to do in this part of this tutorial:

  • Create a modal that will allow us edit any of the images. We could edit the image name (name), caption (caption) and alternative text (alternativeText). This will trigger the updateImageAction server action.
  • Show a list of all the images uploaded to the Strapi backend using the fetchImage service.
  • And a delete function that will call the deleteImage service.

Update Image Update Server Action

To update an image in Strapi, we need to update the image fileInfo field, just as we will see below. Locate the ./src/app/actions.tsx and update the updateImageAction server action with the following code:

`js
// ./src/app/actions.tsx

...
export async function updateImageAction(prevState: any, formData: FormData) {
try {

// get data object from form-data
const data = Object.fromEntries(formData);

// creat new image data
const newImageData = {
  name: data.name || prevState.imageSelected.name,
  alternativeText:
    data.alternativeText || prevState.imageSelected.alternativeText,
  caption: data.caption || prevState.imageSelected.caption,
};

// image ID from form-data
const imageId = data.imageId;

// append `fileInfo` to form-data
const form = new FormData();
form.append("fileInfo", JSON.stringify(newImageData));


const response = await fetch(`${STRAPI_URL}/api/upload?id=${imageId}`, {
  method: "post",
  body: form,
});

// image update result
const result = await response.json();

if (result.error) {
  return {
    uploadError: result.error.message,
    uploadSuccess: null,
  };
}

return {
  uploadError: null,
  uploadSuccess: "Image info updated successfully",
};
Enter fullscreen mode Exit fullscreen mode

} catch (error: any) {
return {
uploadError: error.message,
uploadSuccess: null,
};
}
}
`
In the updateImageAction server action above, we have the following:

  • It takes parameters prevState and formData. prevState, which is the previous state, containing the currently selected image’s data. formData is the form data containing updates to be applied to the image. It should contain name, caption and alternativeText from the update image form modal below.
  • We extract the new image information from formData using Object.fromEntries(formData).
  • We set newImageData with either the new values or existing values from prevState.
  • Appends newImageData as JSON to a FormData object for submission.
  • Sends a POST request to the Strapi upload API endpoint /api/upload?id={imageId} to update the image data, using the image ID (imageId) from data.
  • If an error occurs, it returns an error message; otherwise, it confirms a successful update. ### Create a Modal Form for Image Update Inside the ./src/components/forms component, create a file called UpdateImageModal.tsx and add the following code:

`js
// ./src/components/forms/UpdateImageModal.tsx

"use client";
import { toast } from "react-toastify";
import SubmitButton from "../SubmitButton";
import { useFormState } from "react-dom";
import { updateImageAction } from "@/app/actions";
import { ImageEntry } from "@/app/Types";

// Define the props interface for UpdateImageModal
interface UpdateImageModalProps {
closeModal: () => void;
imageSelected: ImageEntry;
handleFetchImages: () => Promise;
}

export function UpdateImageModal({
closeModal,
imageSelected,
handleFetchImages,
}: UpdateImageModalProps) {

const initialState = {
uploadError: null,
uploadSuccess: null,
imageSelected,
};

const [state, formAction] = useFormState(updateImageAction, initialState);

if (state?.uploadSuccess) {
toast.success(state?.uploadSuccess);
handleFetchImages();
closeModal();
}

return (




onClick={() => {
closeModal();
}}
className="relative w-fit border bg-black text-white px-3 py-1 rounded-md"
>
close





Update File Infos: {imageSelected.name.split(".")[0]}{" "}



Update a specific image in your application.




Name
defaultValue={imageSelected.name?.split(".")[0]}
placeholder={imageSelected.name?.split(".")[0]}
type="text"
name="name"
className="text-sm text-[#71717a] p-5 lg:p-2 border"
/>
Type in a new name
        <div className="flex flex-col space-y-2">
          <label htmlFor="cover">Caption</label>
          <input
            defaultValue={imageSelected?.caption?.split(".")[0]}
            placeholder={imageSelected?.caption?.split(".")[0]}
            type="text"
            name="caption"
            className="text-sm text-[#71717a] p-5 lg:p-2  border"
          />
          <span className="text-sm text-[#71717a]">Give it a caption</span>
        </div>

        <div className="flex flex-col space-y-2">
          <label htmlFor="">Alternative Text</label>
          <input
            defaultValue={imageSelected?.alternativeText?.split(".")[0]}
            placeholder={imageSelected?.alternativeText?.split(".")[0]}
            type="text"
            name="alternativeText"
            className="text-sm text-[#71717a] p-5 lg:p-2  border"
          />
          <span className="text-sm text-[#71717a]">
            Create an alternative text for your image
          </span>
        </div>
        <input type="hidden" name="imageId" value={imageSelected?.id} />

        {state?.uploadError ? (
          <span className="text-red-500">{state?.uploadError}</span>
        ) : null}
        <SubmitButton title="Update" />
      </div>
    </form>
  </div>
</div>

);
}
`
In the modal form above, here is what we did:

  • This modal component above provides a form to update the selected image's metadata or file info (name, caption, and alternative text).
  • It takes the props closeModal function to close the modal. imageSelected, which is the currently selected image for editing. handleFetchImages function to refresh the list of images after an update.
  • It has the states useFormState with updateImageAction to manage form submissions and handle success/error feedback.
  • It displays form fields with placeholders name, caption and alternativeText and default values based on imageSelected.
  • Calls handleFetchImages() and closeModal() when the update is successful.
  • Shows error messages if any occur during the update. Provides a "close" button to exit the modal without updating.

Import UpdateImageModal and Add Delete Function inside the Gallery Component

Now, update the Gallery component to include a delete function and the form modal we created above.

`js
// ./src/components/Gallery.tsx

"use client";

import { useEffect, useState, useRef } from "react";
import Image from "next/image";
import { UpdateImageModal } from "./form/UpdateImageModal";
import { deleteImage, fetchImages } from "@/app/services";
import { toast } from "react-toastify";
import { ImageEntry } from "@/app/Types";

const STRAPI_URL: string = "http://localhost:1337";

export default function Gallery() {
const [images, setImages] = useState([]);
const [selectedImage, setSelectedImage] = useState(null);
const [update, setUpdate] = useState(false);

const handleFetchImages = async () => {
const images = await fetchImages();
setImages(images);
};

const closeModal = () => {
setUpdate(false);
};

const onDeleteImage = async (imageId: number) => {
await deleteImage(imageId);
const newImages = [...images].filter((image) => image.id !== imageId);
setImages(newImages);
toast.success("Image Deleted");
};

useEffect(() => {
handleFetchImages();
}, []);

return (



{update ? (
closeModal={closeModal}
imageSelected={selectedImage as ImageEntry}
handleFetchImages={handleFetchImages}
/>
) : null}
  <div className="w-full my-5">
    <p className="text-lg font-semibold">Update Image Info</p>
    <span className="text-sm text-[#71717a]">
      This is where you find all uploaded images so as update anyone.
    </span>
  </div>

  {images?.length > 0 && (
    <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 w-full pt-10">
      {images?.map((image) => (
        <div className=" group border">
          <div className="h-[200px]">
            <Image
              src={`${STRAPI_URL}${image?.url}`}
              alt={image.name}
              width={400}
              height={300}
              className="transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50"
            />
          </div>

          <div className="flex flex-col gap-y-2 border">
            <p className="flex justify-center items-center text-sm text-center text-[#71717a] h-10">
              {image.name.split(".")[0]?.length > 10
                ? image.name.split(".")[0].slice(0, 10) + "..."
                : image.name.split(".")[0]}
            </p>
            <button
              className="bg-black text-white px-3 py-1 text-sm rounded-md"
              onClick={() => {
                setUpdate(true);
                setSelectedImage(image);
              }}
            >
              Update
            </button>
            <button
              onClick={() => {
                onDeleteImage(image.id);
              }}
              className="bg-red-500 text-white px-3 py-1 rounded-md text-sm"
            >
              Delete
            </button>
          </div>
        </div>
      ))}
    </div>
  )}

  {images?.length <= 0 && (
    <p className="w-full text-orange-300 pt-5">No Images in Gallery.</p>
  )}
</div>

);
}
`
Here is what the Gallery component above does:

  • It displays all uploaded images and allows for updating or deleting individual images.
  • It has the states images, which stores the fetched images, selectedImage, which stores the currently selected image for editing, and update, which controls the visibility of the UpdateImageModal which we imported.
  • It has the functions handleFetchImages that fetches the list of images from the Strapi API, closeModal, which closes the UpdateImageModal and the onDeleteImage, which deletes an image by ID, then updates the state to remove the deleted image from the view.
  • It displays a grid of images, each with an update and delete button.
  • Opens UpdateImageModal when "Update" is clicked and sets selectedImage.
  • Displays a message if no images are available in the gallery.

Update Image Demo

Let's see how our app works when we try to update an image.

Update an Image Demo
Update Image file info Demo

Delete Image Demo

Here is what deleting an image looks like:

Delete an Image
Delete an Image Demo

Upload Image from an API controller in Strapi

What happens when we want to have a custom route that should upload an image to Strapi? This is where Strapi backend customization comes in.

For this part, we want to create a controller that will respond to a POST request on the route favorite-food. So, we need to create a custom controller and a custom route.

Create a Custom Route for Upload

Inside the ./src/API/food/routes folder, create a new file called favorite-food.ts. This will be our new custom route. Add the code below.

`js
// ./src/api/food/routes/favorite-food.ts

// Create route to post favorite food
export default {
routes: [
{
// Path defined with a URL parameter
method: "POST",
path: "/foods/favorite-food",
handler: "api::food.food.uploadImage",
},
],
};
`

In the code above, we specified an HTTP POST method that will handle requests made to /foods/favorite-food route. And we specified that controller uploadImage, which we will create soon, should handle this request. Let's proceed and create the controller.

Create Custom Controller for Upload

Locate the ./src/api/food/controllers/food.ts and replace it with the following code:

`js
// ./src/api/food/controllers/food.ts

/**

  • food controller */

import { factories } from "@strapi/strapi";

export default factories.createCoreController(
"api::food.food",
({ strapi }) => ({
// create custom controller
async uploadImage(ctx) {
const contentType = strapi.contentType("api::food.food");

  // get files
  const { files } = ctx.request;

  // name of input (key)
  const file = files["anyName"];

  // create image using the upload plugin
  const createdFiles = await strapi.plugins.upload.services.upload.upload({
    data: {
      fileInfo: {
        name: file["originalFilename"], // set the original name of the image
        caption: "Caption", // give it a caption
        alternativeText: "Alternative Text", // give it an alternative text
      },
    },
    files: file,
  });

  // send response
  ctx.status = 200;
  ctx.body = {
    data: createdFiles,
  };
},

})
);

`
In the code above, we created a custom controller called uploadImage. It gets the files from the request context sent from the POST request. And then we get the file, which could be a single or multiple of them using the input key, from the request context. After that, we used the upload plugin and the upload method to upload the image or images. Finally, we sent back a response with a 200 HTTP status and the created file or files (createdFiles) as data.

👋 NOTE
You can upload single or multiple images using the example above.

Now, let's test this with Postman.

Create Custom Controller for Upload.png
Create Custom Controller for Upload

From the image above, we can see that we set the name of the input as anyName. This could be any name of your choice. But it has to correspond with the const file = files["anyName"]; we specified in the controller above.

We also selected two image files and then clicked send. The response is returned with the created files in the array data.

Image Upload to Strapi using Postman

Now, we want to perform what we have done using Next.js with Postman. Here are the things we will learn in this section.

  • Single or Multiple Upload.
  • Link an Image to an Entry by Upload.
  • Link an already uploaded image to an entry using its ID.
  • Delete an image.
  • Update an image.

1. Single or Multiple Upload

To perform this, we need to make a POST request to the endpoint /api/upload as form-data. The input name should be files.

Make sure to set the request body as form data. Add the key as files, select the type as a file, and then select an image to upload.

See the image below:
Single or Multiple Upload to Strapi via Postman.png
Single or Multiple Upload to Strapi via Postman

2. Link an Image to an Entry by Upload

In this part, we will upload and at the same time link it to an entry (food entry).

Here, we will make a POST request to /api/upload with the following keys or input values:

  • files: The image file we want to link to an entry.
  • ref: This is the reference type for Food collection. And this should be api::food.food.
  • refId: The id of the food entry. Get an entry ID for your Strapi Food collection.
  • field: The field that will hold the image in the Food collection. This should be cover.

Set the body of the request as form data, add the keys above, and click send.

See the image below:
Link image to an entry by upload.png
Link image to an entry by upload

3. Link an already uploaded image to an entry

How about we link an image existing in our Strapi backend to and entry that is also existing in our Strapi backend? Let's do this using the following steps.

  • Get the id of the image you want to link. To do this, make a GET request to the endpoint /api/upload/files.
  • Get the documentId of the food entry to which you want to link the image. Make a GET request to the endpoint /api/upload/files to get the documentId.
  • Finally, make aPUT request to the endpoint /api/foods/{documentId}, where documentId is the doucment ID of the food entry.
  • The request should be a JSON request with the following structure:

js
{
"data": {
"cover": 13
}
}

See the image below

Link an Image to an entry by its ID.png
Link an Image to an entry by its ID

4. Delete an Image

To delete an image, we will have to make a DELETE request to /api/upload/files/${imageId}, where imageId is the ID of the image we want to delete.

See image below:
Delete an Image in Strapi.png
Delete an image in Strapi via Postman

5. Update an Image File Information in Strapi

Updating an image will require that we make a PUT request to the endpoint /api/upload?id=${imageId}. The imageId here represents the ID of the image we want to update.

We will send our request as JSON using the structure below to update the image file information:

js
{
"fileInfo": {
"name": "new image",
"caption": "a caption",
"alternativeText": "the alternative text"
}
}

See Image below:

Update an Image File Information in Strapi.png
Update an Image File Information in Strapi

Conclusion

In this tutorial, we have learnt about image upload to Strapi via REST API using Next.js and Postman. We covered single and multiple upload, linking an image by id or upload to an entry, upload an image upon creation and single or multiple file upload from an API controller in Strapi CMS. In the same vein, we looked at updating image information and deleting images. The vital Next.js server actions were not left behind as well.

The media library and image upload is a very significant part of any web application. With Strapi headless CMS, you can upload image seamlessly using an frontend technology of your choice. Here is the Github repo to the project for this tutorial.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .