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!
You will need to equip yourself with the following to proceed.
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.
To better understand this tutorial, we will build an image upload system that allows us to perform some image uploads and manipulations in Strapi.
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
.
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.
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
To learn more, you can always visit the Strapi docs.
Strapi supports various media uploads, including audio, files, images, and videos, as shown in the image below.
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.
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.
There are different ways we can upload images in Strapi. They include:
We will see all these in action in the next sections.
To install Strapi 5, run the command below:
npx create-strapi@latest
Or via Yarn.
yarn create strapi
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
If the installation is successful, CD into the Strapi project and run the command below to start the Strapi application.
npm run develop
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
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
Click on the Save button. If successful, this is what the Food collection should look like:
The Food Collection Type
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
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 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
Enable API permissions to the Food collection, just like we did above. Click the Select all checkbox.
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.
Install Next.js by running the command below:
npx create-next-app@latest
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
After successfully installing Next.js, the next step is to start the application. Run the command below:
npm run dev
The command above will start our Next.js application on http://localhost:3000
, as shown below.
Next.js Application
We need to install react-toastify. This will help us with toasts or notifications.
npm i react-toastify
Ensure you CD into the directory of the Next.js app folder above, nextjs-frontend
, before running the command above.
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
.
1// ./src/app/Type.ts
2
3// Image entry data types
4export interface ImageEntry {
5 id: number;
6 documentId: string;
7 name: string;
8 caption: string;
9 alternativeText: string;
10 url: string;
11}
12
13// food entry data types
14export interface FoodEntry {
15 id: number;
16 documentId: string;
17 name: string;
18 cover: ImageEntry;
19}
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.
SubmitButton
Component
Start by creating a submit button component. Create a SubmitButton.tsx
inside the ./src/components
folder.1// ./src/components/SubmitButton.tsx
2
3"use client";
4
5import { useFormStatus } from "react-dom";
6
7export default function SubmitButton({ title }: { title: string }) {
8 const { pending } = useFormStatus();
9
10 return (
11 <button
12 type="submit"
13 aria-disabled={pending}
14 className="bg-black text-sm w-[100px] text-white px-3 py-1 rounded-lg"
15 >
16 {pending ? `${title}ing...` : title}
17 </button>
18 );
19}
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.
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.1// ./src/components/MultipleOrSingleUpload.tsx
2
3"use client";
4
5import SubmitButton from "./SubmitButton";
6
7export default function MultipleOrSingleUpload() {
8
9 return (
10 <form className="flex rounded h-screen lg:w-full">
11 <div className="divide-y w-full">
12 <div className="w-full my-5">
13 <p className=" text-base lg:text-lg">Upload Multiple Files</p>
14 <span className="text-sm text-[#71717a]">
15 Here, you can upload one or more files!
16 </span>
17 </div>
18 <div className="flex flex-col pt-10 gap-y-7">
19 <input
20 type="file"
21 name="files"
22 className="text-sm text-[#71717a] p-5 lg:p-0 border"
23 multiple
24 />
25 <SubmitButton title="Upload" />
26 </div>
27 </div>
28 </form>
29 );
30}
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.
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.1// ./src/components/LinkToSpecificEntry.tsx
2
3"use client";
4
5import { useState } from "react";
6
7enum LinkType {
8 UPLOAD_FILE = "file",
9 GALLERY = "gallery",
10}
11export default function LinkToSpecificEntry() {
12 const [linkType, setLinkType] = useState<string>(LinkType.UPLOAD_FILE);
13
14 return (
15 <div>
16 <div className="w-full my-5">
17 <p className="text-lg font-semibold">Link to a Food</p>
18 <span className="text-sm text-[#71717a]">
19 Link to a specific entry and add a cover (image)
20 </span>
21 </div>
22 <div className="flex justify-between items-center w-full border">
23 <button
24 type="button"
25 onClick={() => setLinkType(LinkType.UPLOAD_FILE)}
26 className={`${
27 linkType === LinkType.UPLOAD_FILE
28 ? "bg-black text-white"
29 : "bg-white text-black"
30 } py-2 basis-1/2 px-3 transition-all duration-500`}
31 >
32 Link By Upload
33 </button>
34 <button
35 type="button"
36 onClick={() => setLinkType(LinkType.GALLERY)}
37 className={`${
38 linkType === LinkType.GALLERY
39 ? "bg-black text-white"
40 : "bg-white text-black"
41 } py-2 basis-1/2 px-3 transition-all duration-500`}
42 >
43 Link from Gallery
44 </button>
45 </div>
46 </div>
47 );
48}
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.
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.1// ./src/components/LinkedImages.tsx
2
3"use client";
4
5import { useState, useEffect } from "react";
6import { Food } from "../Types";
7
8export default function LinkedImages() {
9 const [foods, setFoods] = useState<Food[]>([]);
10
11 const getFoods = async () => {};
12
13 useEffect(() => {
14 getFoods();
15 }, []);
16
17 return (
18 <div className=" w-full">
19 <div className="w-full my-5">
20 <p className="text-lg font-semibold">Entries with Linked Images</p>
21 <span className="text-sm text-[#71717a]">
22 This is where you find all entries along with their linked images.
23 </span>
24 </div>
25 </div>
26 );
27}
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.
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.
1// ./src/components/UploadAtEntryCreation.tsx
2
3"use client";
4
5import SubmitButton from "./SubmitButton";
6
7export default function UploadAtEntryCreation() {
8 return (
9 <form className="flex rounded h-screen lg:w-full">
10 <div className="w-full">
11 <div className="w-full my-5">
12 <p className=" text-base lg:text-lg font-semibold">
13 Upload a File at Entry Creation
14 </p>
15 <span className="text-sm text-[#71717a]">
16 Here, you can create an entry with an image!
17 </span>
18 </div>
19 <div className="flex flex-col pt-10 space-y-2">
20 <label htmlFor="cover">Food Name</label>
21 <input
22 type="text"
23 name="name"
24 placeholder="Name"
25 className="text-sm text-[#71717a] p-2 border"
26 />
27 <span className="text-sm text-[#71717a]">
28 Select the image for this entry!
29 </span>
30 </div>
31 <div className="flex flex-col pt-10 gap-y-7">
32 <span className="flex flex-col space-y-2">
33 <label htmlFor="cover">Cover</label>
34 <input
35 type="file"
36 name="files"
37 className="text-sm text-[#71717a] p-5 lg:p-0 border"
38 />
39 <span className="text-sm text-[#71717a]">
40 Here, you can upload two or more files!
41 </span>
42 </span>
43 <SubmitButton title="Upload" />
44 </div>
45 </div>
46 </form>
47 );
48}
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.
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.1// ./src/components/Gallery.tsx
2
3"use client";
4
5import { useEffect, useState } from "react";
6import { ImageI } from "@/app/Types";
7
8export default function Gallery() {
9 const [images, setImages] = useState<ImageI[]>([]); // state to hold images
10 const [selectedImage, setSelectedImage] = useState<ImageI | null>(null); // for selected image
11 const [update, setUpdate] = useState<boolean> (false); // if image should be updated or not
12
13 // call service to fetch images
14 const handleFetchImages = async () => {};
15
16 // function to close the update modal
17 const closeModal = () => {
18 setUpdate(false);
19 };
20
21 // function to delete an image using the delete service
22 const onDeleteImage = async (imageId: number) => {};
23
24 // fetch images upon mounting
25 useEffect(() => {
26 handleFetchImages();
27 }, []);
28
29 return (
30 <div className=" w-full divide-y">
31 <div className="w-full my-5">
32 <p className="text-lg font-semibold">Welcome to Gallery</p>
33 <span className="text-sm text-[#71717a]">
34 This is where all uploaded images can be found.
35 </span>
36 </div>
37 </div>
38 );
39}
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.
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.1// .src/components/TabsContainer.tsx
2
3"use client";
4
5import { ToastContainer } from "react-toastify";
6import "react-toastify/dist/ReactToastify.css";
7
8import MultipleOrSingleUpload from "./MultipleOrSingleUpload";
9
10import { useState } from "react";
11import LinkToSpecificEntry from "./LinkToSpecificEntry";
12import LinkedImages from "./LinkedImages";
13import Gallery from "./Gallery";
14import UploadAtEntryCreation from "./UploadAtEntryCreation";
15
16// tabs for image uploads and manipulations
17enum Tabs {
18 MULTIPLE_OR_SINGLE = "multipleOrSingle",
19 SPECIFIC_ENTRY = "specificEntry",
20 LINKED_IMAGES = "linkedImages",
21 ENTRY_CREATION = "entryCreation",
22 GALLERY = "gallery",
23}
24
25export default function TabsContainer() {
26 const [tab, setTab] = useState<string>(Tabs.MULTIPLE_OR_SINGLE);
27
28 return (
29 <div>
30 <ToastContainer />
31
32 <div className="flex flex-col lg:flex-row w-full h-full lg:p-10 gap-x-10 ">
33 <div className="lg:flex lg:flex-col text-sm items-start h-screen basis-2/6 w-full pt-5 ">
34 <button
35 className={`${
36 tab == Tabs.MULTIPLE_OR_SINGLE
37 ? " px-5 lg:px-4 text-red-500 lg:text-inherit bg-[#f4f4f5] "
38 : null
39 } px-4 py-2 rounded-lg text-left w-full`}
40 onClick={() => {
41 setTab(Tabs.MULTIPLE_OR_SINGLE);
42 }}
43 >
44 Upload Multiple Files
45 </button>
46 <button
47 className={`${
48 tab == Tabs.SPECIFIC_ENTRY
49 ? " px-5 lg:px-4 text-red-500 lg:text-inherit bg-[#f4f4f5] "
50 : null
51 } px-4 py-2 rounded-lg text-left w-full`}
52 onClick={() => {
53 setTab(Tabs.SPECIFIC_ENTRY);
54 }}
55 >
56 Link to a Specific Entry
57 </button>
58 <button
59 className={`${
60 tab == Tabs.LINKED_IMAGES
61 ? " px-5 lg:px-4 text-red-500 lg:text-inherit bg-[#f4f4f5] "
62 : null
63 } px-4 py-2 rounded-lg text-left w-full`}
64 onClick={() => {
65 setTab(Tabs.LINKED_IMAGES);
66 }}
67 >
68 Linked Images
69 </button>
70 <button
71 className={`${
72 tab == Tabs.ENTRY_CREATION
73 ? " px-5 lg:px-4 text-red-500 lg:text-inherit bg-[#f4f4f5] "
74 : null
75 } px-4 py-2 rounded-lg text-left w-full`}
76 onClick={() => {
77 setTab(Tabs.ENTRY_CREATION);
78 }}
79 >
80 Upload at Entry creation
81 </button>
82 <button
83 className={`${
84 tab == Tabs.GALLERY
85 ? " px-5 lg:px-4 text-red-500 lg:text-inherit bg-[#f4f4f5] "
86 : null
87 } px-4 py-2 rounded-lg text-left w-full`}
88 onClick={() => {
89 setTab(Tabs.GALLERY);
90 }}
91 >
92 Gallery
93 </button>
94 </div>
95
96 <div className="h-screen w-full flex flex-col gap-y-10 basis-4/6">
97 {tab === Tabs.MULTIPLE_OR_SINGLE ? (
98 <MultipleOrSingleUpload />
99 ) : tab === Tabs.SPECIFIC_ENTRY ? (
100 <LinkToSpecificEntry />
101 ) : tab === Tabs.LINKED_IMAGES ? (
102 <LinkedImages />
103 ) : tab === Tabs.ENTRY_CREATION ? (
104 <UploadAtEntryCreation />
105 ) : (
106 <Gallery />
107 )}
108 </div>
109 </div>
110 </div>
111 );
112}
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.
We want all the tab components created above to be visible. Here are the steps we will take to achieve this:
./src/app/global.css
file and update it with the following code:1@tailwind base;
2@tailwind components;
3@tailwind utilities;
By updating the above, we have removed any custom default CSS.
TabsContainer
Component
Update the code in the ./src/page.tsx
file with the following:1// ./src/app/page.tsx
2
3import TabsContainer from "./components/TabsContainer";
4
5export default function Home() {
6 return (
7 <div className="min-h-screen p-3 lg:p-20">
8 <div className=" lg:px-14 py-5">
9 <p className="text-2xl lg:text-4xl font-bold mb-4">
10 Image Upload to Strapi
11 </p>
12 <span className="text-slate-400">
13 Let's demonstrate image upload to Strapi Content types using the REST
14 API
15 </span>
16 <TabsContainer />
17 </div>
18 </div>
19 );
20}
This is what our app should look like
Tabs for our application
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:
1"use server";
2
3const STRAPI_URL: string = "http://localhost:1337";
4
5// accepted image types
6const ACCEPTED_IMAGE_TYPES = [
7 "image/jpeg",
8 "image/jpg",
9 "image/png",
10 "image/webp",
11];
12
13// upload single or multiple images
14export async function uploadMultipleOrSingleAction(
15 prevState: any,
16 formData: FormData
17) {}
18
19// Upload an Image and link to an entry
20export async function LinkByUploadAction(
21 prevState: any,
22 formData: FormData
23) {}
24
25// Link an Uploaded image to an Entry by ID
26export async function linkFromGalleryAction(
27 prevState: any,
28 formData: FormData
29) {}
30
31// Upload image at entry creation
32export async function uploadAtEntryCreationAction(
33 prevState: any,
34 formData: FormData
35){}
36
37// update mage file info action
38export async function updateImageAction(
39 prevState: any,
40 formData: FormData
41){}
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.
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.
1// ./src/app/services.ts
2
3
4const STRAPI_URL: string = "http://localhost:1337";
5
6export const fetchImages = async () => {
7 try {
8 // fetch images from Strapi backend
9 const response = await fetch(`${STRAPI_URL}/api/upload/files`);
10
11 // if response is not ok
12 if (!response.ok) {
13 const errorDetails = await response.text();
14 throw new Error(
15 `Error fetching images: ${response.status} ${response.statusText} - ${errorDetails}`
16 );
17 }
18
19 // return fetched images
20 const result = await response.json();
21 return result;
22 } catch (error: any) {
23 throw new Error(`Failed to fetch images: ${error.message}`);
24 }
25};
26
27export const fetchFoods = async () => {
28 try {
29 // fetch foods from Strapi backend
30 const response = await fetch(`${STRAPI_URL}/api/foods?populate=*`);
31
32 // if response is not ok
33 if (!response.ok) {
34 const errorDetails = await response.text();
35 throw new Error(
36 `Error fetching Foods: ${response.status} ${response.statusText} - ${errorDetails}`
37 );
38 }
39
40 // return fetched foods
41 const result = await response.json();
42 return result;
43 } catch (error: any) {
44 // throw new error
45 throw new Error(`Failed to fetch images: ${error.message}`);
46 }
47};
48
49export const deleteImage = async (imageId: number) => {
50 try {
51 // make a DELETE request using image id.
52 const response = await fetch(`${STRAPI_URL}/api/upload/files/${imageId}`, {
53 method: "DELETE",
54 });
55
56 // if response is not ok
57 if (!response.ok) {
58 const errorDetails = await response.text();
59 throw new Error(
60 `Error deleting food entry: ${response.status} ${response.statusText} - ${errorDetails}`
61 );
62 }
63 } catch (error: any) {
64 // throw new error
65 throw new Error(`Failed to delete entry: ${error.message}`);
66 }
67};
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.
Now, let's modify the MultipleOrSingleUpload
component and the uploadMultipleOrSingleAction
to implement this.
We will do this in 2 steps:
uploadMultipleOrSingleAction
Server ActionUpdate the uploadMultipleOrSingleAction
server action so as to invoke it in the MultipleOrSingleImageUpload
component.
1// ./src/actions.ts
2...
3export async function uploadMultipleOrSingleAction(
4 prevState: any,
5 formData: FormData
6) {
7 try {
8 const response = await fetch(`${STRAPI_URL}/api/upload`, {
9 method: "post",
10 body: formData,
11 });
12
13 const result = await response.json();
14
15 if (result.error) {
16 return {
17 uploadError: result.error.message,
18 uploadSuccess: null,
19 };
20 }
21
22 return {
23 uploadError: null,
24 uploadSuccess: "Images uploaded successfully",
25 };
26 } catch (error: any) {
27 return {
28 uploadError: error.message,
29 uploadSuccess: null,
30 };
31 }
32}
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.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.MultipleOrSingleUpload
ComponentLet's modify the MultipleOrSingleUpload
component form to trigger the server action above.
1// .src/components/MultipleOrSingleImageUpload
2
3"use client";
4import { uploadMultipleOrSingleAction } from "@/app/actions";
5import { useFormState } from "react-dom";
6import { Ref, useRef } from "react";
7import SubmitButton from "./SubmitButton";
8import { toast } from "react-toastify";
9
10const initialState = {
11 uploadError: null,
12 uploadSuccess: null,
13};
14
15export default function MultipleOrSingleImageUpload() {
16 const [state, formAction] = useFormState(
17 uploadMultipleOrSingleAction,
18 initialState
19 );
20 const formRef = useRef<HTMLFormElement | null>(null);
21
22 if (state?.uploadSuccess) {
23 formRef?.current?.reset();
24 toast.success(state?.uploadSuccess);
25 }
26
27 return (
28 <form
29 ref={formRef}
30 action={formAction}
31 className="flex rounded h-screen lg:w-full"
32 >
33 <div className="divide-y w-full">
34 <div className="w-full my-5">
35 <p className=" text-base lg:text-lg">Upload Multiple Files</p>
36 <span className="text-sm text-[#71717a]">
37 Here, you can upload two or more files!
38 </span>
39 </div>
40 <div className="flex flex-col pt-10 gap-y-7">
41 <input
42 type="file"
43 name="files"
44 className="text-sm text-[#71717a] p-5 lg:p-0 border"
45 multiple
46 />
47 {state?.uploadError ? (
48 <span className="text-red-500">{state?.uploadError}</span>
49 ) : null}
50 <SubmitButton title="Upload" />
51 </div>
52 </div>
53 </form>
54 );
55}
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). 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.<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
.state.uploadError
contains a message, it will be displayed in red below the input field using a conditional rendering block.state.uploadSuccess
contains a success message (indicating that the files were uploaded successfully), the form is reset by calling formRef.current.reset()
. 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
When we visit the Strapi backend, we should see the images we just uploaded.
Images in Strapi Backend
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.
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.
LinkByUploadAction
Server Action1// ./src/actions.ts
2
3...
4export async function LinkByUploadAction(prevState: any, formData: FormData) {
5 try {
6 // Convert formData into an object to extract data
7 const data = Object.fromEntries(formData);
8
9 // Create a new FormData object to send to the server
10 const formDataToSend = new FormData();
11 formDataToSend.append("files", data.files); // The image file
12 formDataToSend.append("ref", data.ref); // The reference type for Food collection
13 formDataToSend.append("refId", data.refId); // The ID of the food entry
14 formDataToSend.append("field", data.field); // The specific field to which the image is linked, i.e., "cover"
15
16 // Make the API request to Strapi to upload the file and link it to the specific entry
17 const response = await fetch(`${STRAPI_URL}/api/upload`, {
18 method: "post",
19 body: formDataToSend,
20 });
21
22 // upload respone
23 const result = await response.json();
24
25 // Handle potential errors from the API response
26 if (result.error) {
27 return {
28 uploadError: result.error.message,
29 uploadSuccess: null,
30 };
31 }
32
33 // Return success if the upload and linking are successful
34 return {
35 uploadSuccess: "Image linked to a food successfully!",
36 uploadError: null,
37 };
38 } catch (error: any) {
39 // Catch any errors that occur during the process
40 return {
41 uploadError: error.message,
42 uploadSuccess: null,
43 };
44 }
45}
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.
./src/components
folder, create a folder called form
. Inside the new form
folder, create a file called LinkByUpload.tsx
and add the following code:1// ./src/components/LinkByUpload.tsx
2
3import { useEffect, useState } from "react";
4import { useFormState } from "react-dom";
5import { toast } from "react-toastify";
6import { LinkByUploadAction } from "@/app/actions";
7
8import SubmitButton from "@/components/SubmitButton";
9import { fetchFoods } from "@/app/services";
10import { FoodEntry } from "@/app/Types";
11
12// initial state
13const initialState = {
14 uploadError: null,
15 uploadSuccess: null,
16};
17
18export default function LinkByUpload() {
19 const [state, formAction] = useFormState(LinkByUploadAction, initialState);
20 const [foods, setFoods] = useState<FoodEntry[]>([]);
21
22 const handleFetchFoods = async () => {
23 const result = await fetchFoods();
24 setFoods(result?.data);
25 };
26
27 useEffect(() => {
28 handleFetchFoods();
29 }, []);
30
31 if (state?.uploadSuccess) {
32 toast.success(state?.uploadSuccess);
33 }
34
35 return (
36 <div className="w-full">
37 <form action={formAction} className="flex rounded w-full">
38 <div className="flex flex-col pt-10 gap-y-7 w-full">
39 <div className="flex flex-col space-y-2">
40 <label htmlFor="name">Food</label>
41 <select name="refId" className="border p-2 text-[#71717a] text-sm">
42 {foods.map((food) => {
43 return (
44 <option key={food.id} value={food.id}>
45 {" "}
46 {food.name}
47 </option>
48 );
49 })}
50 </select>
51 <span className="text-sm text-[#71717a]">
52 Select the food you want to add Image to
53 </span>
54 <div className="flex flex-col space-y-2 pt-10">
55 <label htmlFor="cover">Cover</label>
56 <input
57 type="file"
58 name="files"
59 className="text-sm text-[#71717a] border"
60 />
61 <span className="text-sm text-[#71717a]">
62 Select an image to link to a food
63 </span>
64 </div>
65
66 {state?.uploadError ? (
67 <span className="text-red-500">{state?.uploadError}</span>
68 ) : null}
69 <input type="hidden" name="ref" value="api::food.food" />
70 <input type="hidden" name="field" value="cover" />
71 <div className="pt-5">
72 <SubmitButton title="Link" />
73 </div>
74 </div>
75 </div>
76 </form>
77 </div>
78 );
79}
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.
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:1// ./src/components/LinkToSpecificEntry.tsx
2
3"use client";
4
5import { useState } from "react";
6
7import LinkByUpload from "./form/LinkByUpload";
8
9// linking type
10enum LinkType {
11 UPLOAD = "upload",
12 GALLERY = "gallery",
13}
14
15export default function LinkToSpecificEntry() {
16 const [linkType, setLinkType] = useState<LinkType>(LinkType.UPLOAD);
17
18 return (
19 <div>
20 <div className="w-full my-5">
21 <p className="text-lg font-semibold">Link to a Food</p>
22 <span className="text-sm text-[#71717a]">
23 Link to a specific entry and add a cover (image)
24 </span>
25 </div>
26 <div className="flex justify-between items-center w-full border">
27 <button
28 type="button"
29 onClick={() => setLinkType(LinkType.UPLOAD)}
30 className={`${
31 linkType === LinkType.UPLOAD
32 ? "bg-black text-white"
33 : "bg-white text-black"
34 } py-2 basis-1/2 px-3 transition-all duration-500`}
35 >
36 Link By Upload
37 </button>
38 <button
39 type="button"
40 onClick={() => setLinkType(LinkType.GALLERY)}
41 className={`${
42 linkType === LinkType.GALLERY
43 ? "bg-black text-white"
44 : "bg-white text-black"
45 } py-2 basis-1/2 px-3 transition-all duration-500`}
46 >
47 Link from Gallery
48 </button>
49 </div>
50 {linkType === LinkType.UPLOAD ? <LinkByUpload /> : null }
51 </div>
52 );
53}
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 an image by uploading to an entry
Head over to the Strapi backend to see if this was successful.
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 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 & publish
Once that is done, we can now see the linked image for Pizza!
Linked Image is now displayed
We can now proceed to link an already uploaded image to an existing entry.
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.
./next.config.mjs
file and replace the content with the following code:1/** @type {import('next').NextConfig} */
2const nextConfig = {
3 images: {
4 remotePatterns: [
5 {
6 hostname: "localhost"
7 }
8 ]
9 }
10};
11
12export default nextConfig;
With this, we have configured localhost
to be a secure link to our images.
LinkFromGallery
Server Action
Update the LinkFromGalleryAction
action with the following code:1// ./src/app/actions.ts
2
3...
4export async function linkFromGalleryAction(
5 prevState: any,
6 formData: FormData
7) {
8 try {
9 const data = Object.fromEntries(formData);
10
11 const response = await fetch(`${STRAPI_URL}/api/foods/${data?.refId}`, {
12 method: "PUT",
13 headers: {
14 "Content-Type": "application/json",
15 },
16 body: JSON.stringify({
17 data: {
18 cover: data?.imageId,
19 },
20 }),
21 });
22
23 const result = await response.json();
24
25 if (result.error) {
26 return {
27 uploadError: result.error.message,
28 uploadSuccess: null,
29 };
30 }
31 return {
32 uploadSuccess: "Image linked to a food successfully!",
33 uploadError: null,
34 };
35 } catch (error: any) {
36 return {
37 uploadError: error.message,
38 uploadSuccess: null,
39 };
40 }
41}
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.
./src/components/form
folder, create another file called LinkByGallery.tsx
and add the following code:1// ./src/components/form/LinkByGallery.tsx
2
3import { useEffect, useState, useRef } from "react";
4import { useFormState } from "react-dom";
5import Image from "next/image";
6import { fetchFoods, fetchImages } from "@/app/services";
7import { toast } from "react-toastify";
8import SubmitButton from "@/components/SubmitButton";
9import { linkFromGalleryAction } from "@/app/actions";
10import { FoodEntry, ImageEntry } from "@/app/Types";
11
12const STRAPI_URL: string = "http://localhost:1337";
13
14const initialState = {
15 uploadError: null,
16 uploadSuccess: null,
17};
18export default function LinkByGallery() {
19 const [state, formAction] = useFormState(linkFromGalleryAction, initialState);
20 const [foods, setFoods] = useState<FoodEntry[]>([]);
21 const [images, setImages] = useState<ImageEntry[]>([]);
22 const [selectedImageId, setSelectedImageId] = useState<number | string>("");
23
24 const formRef = useRef<HTMLFormElement | null>(null);
25
26 const handleFetchFoods = async () => {
27 const result = await fetchFoods();
28 setFoods(result?.data);
29 };
30
31 const handleFetchImages = async () => {
32 const images = await fetchImages();
33 setImages(images);
34 };
35
36 if (state?.uploadSuccess) {
37 formRef?.current?.reset();
38 toast.success(state?.uploadSuccess);
39 state.uploadSuccess = "";
40 }
41
42 useEffect(() => {
43 handleFetchFoods();
44 handleFetchImages();
45 }, []);
46 return (
47 <div className="w-full">
48 <form ref={formRef} action={formAction} className="flex rounded w-full">
49 <div className="w-full">
50 <div className="flex flex-col pt-10 gap-y-7">
51 <div className="flex flex-col space-y-2">
52 <label htmlFor="name">Food</label>
53 <select
54 name="refId"
55 className="border p-2 text-[#71717a] text-sm w-full"
56 id="food"
57 >
58 {foods.map((food) => {
59 return <option value={food.documentId}>{food.name}</option>;
60 })}
61 </select>
62 <span className="text-sm text-[#71717a]">
63 Select the food you want to add Image to
64 </span>
65 </div>
66 </div>
67 {images?.length > 0 ? (
68 <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 w-full pt-10">
69 {images?.map((image) => (
70 <div
71 className={`${
72 selectedImageId === image?.id ? "border-2 border-black" : ""
73 } h-[200px] w-[400]px group relative`}
74 onClick={() => setSelectedImageId(image?.id)}
75 >
76 {selectedImageId === image?.id && (
77 <div className="flex items-center justify-center absolute top-0 left-0 w-full h-full">
78 <span className="absolute z-50 text-white font-extrabold">
79 selected
80 </span>
81 </div>
82 )}
83 <Image
84 src={`${STRAPI_URL}${image?.url}`}
85 alt={image.name}
86 width={400}
87 height={100}
88 className={` ${
89 selectedImageId === image?.id ? " opacity-50 " : " "
90 } transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50`}
91 />
92 </div>
93 ))}
94 </div>
95 ) : (
96 <p className="w-full text-orange-300 pt-5">No Images in Gallery.</p>
97 )}
98 <p className="pt-2">
99 {state?.uploadError ? (
100 <span className="text-red-500">{state?.uploadError}</span>
101 ) : null}
102 </p>
103 <input type="hidden" name="imageId" value={selectedImageId} />
104 <div className="pt-5">
105 <SubmitButton title="Link" />
106 </div>
107 </div>
108 </form>
109 </div>
110 );
111}
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.
LinkByGallery
to the LinkToSpecificEntry
component.1// .src/components/LinkToSpecificEntry.tsx
2
3"use client";
4
5import { useState } from "react";
6
7import LinkByUpload from "./form/LinkByUpload";
8import LinkByGallery from "./form/LinkByGallery";
9
10// linking type
11enum LinkType {
12 UPLOAD = "upload",
13 GALLERY = "gallery",
14}
15
16export default function LinkToSpecificEntry() {
17 const [linkType, setLinkType] = useState<LinkType>(LinkType.UPLOAD);
18
19 return (
20 <div>
21 <div className="w-full my-5">
22 <p className="text-lg font-semibold">Link to a Food</p>
23 <span className="text-sm text-[#71717a]">
24 Link to a specific entry and add a cover (image)
25 </span>
26 </div>
27 <div className="flex justify-between items-center w-full border">
28 <button
29 type="button"
30 onClick={() => setLinkType(LinkType.UPLOAD)}
31 className={`${
32 linkType === LinkType.UPLOAD
33 ? "bg-black text-white"
34 : "bg-white text-black"
35 } py-2 basis-1/2 px-3 transition-all duration-500`}
36 >
37 Link By Upload
38 </button>
39 <button
40 type="button"
41 onClick={() => setLinkType(LinkType.GALLERY)}
42 className={`${
43 linkType === LinkType.GALLERY
44 ? "bg-black text-white"
45 : "bg-white text-black"
46 } py-2 basis-1/2 px-3 transition-all duration-500`}
47 >
48 Link from Gallery
49 </button>
50 </div>
51 {linkType === LinkType.UPLOAD ? <LinkByUpload /> : <LinkByGallery />}
52 </div>
53 );
54}
Let's see this in action:
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
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.
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.
1// ./src/components/LinkedImages.tsx
2
3"use client";
4
5import { useEffect, useState } from "react";
6import Image from "next/image";
7import { fetchFoods } from "@/app/services";
8import { FoodEntry } from "@/app/Types";
9
10const STRAPI_URL: string = "http://localhost:1337";
11
12export default function LinkedImages() {
13 const [foods, setFoods] = useState<FoodEntry[]>([]); // foods state
14
15 // get foods using the featchFoods service
16 const getFoods = async () => {
17 const result = await fetchFoods();
18 setFoods(result?.data);
19 };
20
21 // fetch foods upon mounting
22 useEffect(() => {
23 getFoods();
24 }, []);
25
26 return (
27 <div className=" w-full">
28 <div className="w-full my-5">
29 <p className="text-lg font-semibold">Entries with Linked Images</p>
30 <span className="text-sm text-[#71717a]">
31 This is where you find all uploaded images so as update anyone.
32 </span>
33 </div>
34 {foods?.length > 0 && (
35 <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-2">
36 {foods?.map((food) => (
37 <div key={food.id} className="group h-full border">
38 <div className="h-full">
39 <p className="p-2 font-bold border-b flex flex-col items-center justify-center bg-black text-white">
40 <span>{food?.name}</span>
41 </p>
42 <div className="h-[200px] w-[400]px">
43 {food?.cover?.url ? (
44 <Image
45 src={`${STRAPI_URL}/${food?.cover?.url}`}
46 alt={food?.cover?.name}
47 width={200}
48 height={300}
49 className="transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50"
50 />
51 ) : (
52 <div className="h-full flex flex-col justify-center items-center text-sm text-[#71717a]">
53 No linked image
54 </div>
55 )}
56 </div>
57 </div>
58 </div>
59 ))}
60 </div>
61 )}
62 {foods?.length <= 0 && <p>No foods and images linked</p>}
63 </div>
64 );
65}
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
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.
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.
uploadAtEntryCreationAction
Server ActionWe will have to update the uploadAtEntryCreationAction
server action with the following code:
1// ./src/actions.ts
2...
3
4export async function uploadAtEntryCreationAction(
5 prevState: any,
6 formData: FormData
7) {
8 try {
9 const data = Object.fromEntries(formData);
10
11 // upload file
12 const uploadResponse = await fetch(`${STRAPI_URL}/api/upload`, {
13 method: "post",
14 body: formData,
15 });
16
17 // get result of upload
18 const uploadedImage = await uploadResponse.json();
19
20 // if error
21 if (uploadedImage.error) {
22 return {
23 uploadError: uploadedImage.error.message,
24 uploadSuccess: null,
25 };
26 }
27
28 // create entry
29 const newEntry = {
30 data: {
31 name: data.name,
32 cover: uploadedImage[0]?.id,
33 },
34 };
35
36 // Create entry API request
37 const response = await fetch(`${STRAPI_URL}/api/foods`, {
38 method: "post",
39 headers: {
40 "Content-Type": "application/json",
41 },
42 body: JSON.stringify(newEntry),
43 });
44
45 const result = await response.json();
46
47 if (result.error) {
48 return {
49 uploadError: result.error.message,
50 uploadSuccess: null,
51 };
52 }
53
54 return {
55 uploadError: null,
56 uploadSuccess: "Images uploaded successfully",
57 };
58 } catch (error: any) {
59 return {
60 uploadError: null,
61 uploadSuccess: "Images uploaded successfully",
62 };
63 }
64}
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.
UploadAtEntryCreation
ComponentIn order to use the server action above, we have to modify the ./src/components/UploadAtEntryCreation.tsx
file.
1// ./src/components/UploadAtEntryCreation.tsx
2
3"use client";
4import { uploadAtEntryCreationAction } from "@/app/actions";
5import { useFormState } from "react-dom";
6import { useEffect, useRef } from "react";
7import SubmitButton from "./SubmitButton";
8import { toast } from "react-toastify";
9
10const initialState = {
11 uploadError: null,
12 uploadSuccess: null,
13};
14
15export default function UploadAtEntryCreation() {
16 const [state, formAction] = useFormState(
17 uploadAtEntryCreationAction,
18 initialState
19 );
20 const formRef = useRef<HTMLFormElement>(null);
21
22 useEffect(() => {
23 formRef?.current?.reset();
24 toast.success(state?.uploadSuccess);
25 }, [state]);
26
27 return (
28 <form
29 ref={formRef}
30 action={formAction}
31 className="flex rounded h-screen lg:w-full"
32 >
33 <div className="w-full">
34 <div className="w-full my-5">
35 <p className=" text-base lg:text-lg">
36 Upload a File at Entry Creation
37 </p>
38 <span className="text-sm text-[#71717a]">
39 Here, you can create an entry with an image!
40 </span>
41 </div>
42 <div className="flex flex-col pt-10 space-y-2">
43 <label htmlFor="cover">Food Name</label>
44 <input
45 type="text"
46 name="name"
47 placeholder="Name"
48 className="text-sm text-[#71717a] p-2 border"
49 />
50 <span className="text-sm text-[#71717a]">
51 Select the image for this entry!
52 </span>
53 </div>
54 <div className="flex flex-col pt-10 gap-y-7">
55 <span className="flex flex-col space-y-2">
56 <label htmlFor="cover">Cover</label>
57 <input
58 type="file"
59 name="files"
60 className="text-sm text-[#71717a] p-5 lg:p-0 border"
61 />
62 <span className="text-sm text-[#71717a]">
63 Here, you can upload two or more files!
64 </span>
65 </span>
66 {state?.uploadError ? (
67 <span className="text-red-500">{state?.uploadError}</span>
68 ) : null}
69 <SubmitButton title="Upload" />
70 </div>
71 </div>
72 </form>
73 );
74}
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:
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:
name
), caption (caption
) and alternative text (alternativeText
). This will trigger the updateImageAction
server action.fetchImage
service.deleteImage
service.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:
1// ./src/app/actions.tsx
2
3...
4export async function updateImageAction(prevState: any, formData: FormData) {
5 try {
6
7 // get data object from form-data
8 const data = Object.fromEntries(formData);
9
10 // creat new image data
11 const newImageData = {
12 name: data.name || prevState.imageSelected.name,
13 alternativeText:
14 data.alternativeText || prevState.imageSelected.alternativeText,
15 caption: data.caption || prevState.imageSelected.caption,
16 };
17
18 // image ID from form-data
19 const imageId = data.imageId;
20
21 // append `fileInfo` to form-data
22 const form = new FormData();
23 form.append("fileInfo", JSON.stringify(newImageData));
24
25
26 const response = await fetch(`${STRAPI_URL}/api/upload?id=${imageId}`, {
27 method: "post",
28 body: form,
29 });
30
31 // image update result
32 const result = await response.json();
33
34 if (result.error) {
35 return {
36 uploadError: result.error.message,
37 uploadSuccess: null,
38 };
39 }
40
41 return {
42 uploadError: null,
43 uploadSuccess: "Image info updated successfully",
44 };
45 } catch (error: any) {
46 return {
47 uploadError: error.message,
48 uploadSuccess: null,
49 };
50 }
51}
In the updateImageAction
server action above, we have the following:
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.Object.fromEntries(formData)
.newImageData
with either the new values or existing values from prevState
.newImageData
as JSON to a FormData object for submission./api/upload?id={imageId}
to update the image data, using the image ID (imageId
) from data.Inside the ./src/components/forms
component, create a file called UpdateImageModal.tsx
and add the following code:
1// ./src/components/forms/UpdateImageModal.tsx
2
3"use client";
4import { toast } from "react-toastify";
5import SubmitButton from "../SubmitButton";
6import { useFormState } from "react-dom";
7import { updateImageAction } from "@/app/actions";
8import { ImageEntry } from "@/app/Types";
9
10// Define the props interface for UpdateImageModal
11interface UpdateImageModalProps {
12 closeModal: () => void;
13 imageSelected: ImageEntry;
14 handleFetchImages: () => Promise<void>;
15}
16
17export function UpdateImageModal({
18 closeModal,
19 imageSelected,
20 handleFetchImages,
21}: UpdateImageModalProps) {
22
23 const initialState = {
24 uploadError: null,
25 uploadSuccess: null,
26 imageSelected,
27 };
28
29 const [state, formAction] = useFormState(updateImageAction, initialState);
30
31 if (state?.uploadSuccess) {
32 toast.success(state?.uploadSuccess);
33 handleFetchImages();
34 closeModal();
35 }
36
37 return (
38 <div className="fixed top-0 left-0 w-full h-full z-50 bg-black bg-opacity-55 flex flex-col justify-center items-center">
39 <div className="w-fit h-fit lg:w-[500px] border flex flex-col bg-white ">
40 <div className="flex justify-end p-2">
41 <button
42 onClick={() => {
43 closeModal();
44 }}
45 className="relative w-fit border bg-black text-white px-3 py-1 rounded-md"
46 >
47 close
48 </button>
49 </div>
50 <form className="p-10" action={formAction}>
51 <div className="w-full my-5">
52 <p className="text-base lg:text-lg">
53 Update File Infos: {imageSelected.name.split(".")[0]}{" "}
54 </p>
55 <span className="text-sm text-[#71717a]">
56 Update a specific image in your application.
57 </span>
58 </div>
59 <div className="flex flex-col pt-5 gap-y-7">
60 <div className="flex flex-col space-y-2">
61 <label htmlFor="cover">Name</label>
62 <input
63 defaultValue={imageSelected.name?.split(".")[0]}
64 placeholder={imageSelected.name?.split(".")[0]}
65 type="text"
66 name="name"
67 className="text-sm text-[#71717a] p-5 lg:p-2 border"
68 />
69 <span className="text-sm text-[#71717a]">Type in a new name</span>
70 </div>
71
72 <div className="flex flex-col space-y-2">
73 <label htmlFor="cover">Caption</label>
74 <input
75 defaultValue={imageSelected?.caption?.split(".")[0]}
76 placeholder={imageSelected?.caption?.split(".")[0]}
77 type="text"
78 name="caption"
79 className="text-sm text-[#71717a] p-5 lg:p-2 border"
80 />
81 <span className="text-sm text-[#71717a]">Give it a caption</span>
82 </div>
83
84 <div className="flex flex-col space-y-2">
85 <label htmlFor="">Alternative Text</label>
86 <input
87 defaultValue={imageSelected?.alternativeText?.split(".")[0]}
88 placeholder={imageSelected?.alternativeText?.split(".")[0]}
89 type="text"
90 name="alternativeText"
91 className="text-sm text-[#71717a] p-5 lg:p-2 border"
92 />
93 <span className="text-sm text-[#71717a]">
94 Create an alternative text for your image
95 </span>
96 </div>
97 <input type="hidden" name="imageId" value={imageSelected?.id} />
98
99 {state?.uploadError ? (
100 <span className="text-red-500">{state?.uploadError}</span>
101 ) : null}
102 <SubmitButton title="Update" />
103 </div>
104 </form>
105 </div>
106 </div>
107 );
108}
In the modal form above, here is what we did:
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.useFormState
with updateImageAction
to manage form submissions and handle success/error feedback.name
, caption
and alternativeText
and default values based on imageSelected
.handleFetchImages()
and closeModal()
when the update is successful.UpdateImageModal
and Add Delete Function inside the Gallery ComponentNow, update the Gallery component to include a delete function and the form modal we created above.
1// ./src/components/Gallery.tsx
2
3"use client";
4
5import { useEffect, useState, useRef } from "react";
6import Image from "next/image";
7import { UpdateImageModal } from "./form/UpdateImageModal";
8import { deleteImage, fetchImages } from "@/app/services";
9import { toast } from "react-toastify";
10import { ImageEntry } from "@/app/Types";
11
12const STRAPI_URL: string = "http://localhost:1337";
13
14export default function Gallery() {
15 const [images, setImages] = useState<ImageEntry[]>([]);
16 const [selectedImage, setSelectedImage] = useState<ImageEntry | null>(null);
17 const [update, setUpdate] = useState<boolean>(false);
18
19 const handleFetchImages = async () => {
20 const images = await fetchImages();
21 setImages(images);
22 };
23
24 const closeModal = () => {
25 setUpdate(false);
26 };
27
28 const onDeleteImage = async (imageId: number) => {
29 await deleteImage(imageId);
30 const newImages = [...images].filter((image) => image.id !== imageId);
31 setImages(newImages);
32 toast.success("Image Deleted");
33 };
34
35 useEffect(() => {
36 handleFetchImages();
37 }, []);
38
39 return (
40 <div className=" w-full divide-y">
41 <div>
42 {update ? (
43 <UpdateImageModal
44 closeModal={closeModal}
45 imageSelected={selectedImage as ImageEntry}
46 handleFetchImages={handleFetchImages}
47 />
48 ) : null}
49 </div>
50
51 <div className="w-full my-5">
52 <p className="text-lg font-semibold">Update Image Info</p>
53 <span className="text-sm text-[#71717a]">
54 This is where you find all uploaded images so as update anyone.
55 </span>
56 </div>
57
58 {images?.length > 0 && (
59 <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-5 w-full pt-10">
60 {images?.map((image) => (
61 <div className=" group border">
62 <div className="h-[200px]">
63 <Image
64 src={`${STRAPI_URL}${image?.url}`}
65 alt={image.name}
66 width={400}
67 height={300}
68 className="transition-all duration-300 opacity-100 h-full w-full max-w-full rounded-lg group-hover:opacity-50"
69 />
70 </div>
71
72 <div className="flex flex-col gap-y-2 border">
73 <p className="flex justify-center items-center text-sm text-center text-[#71717a] h-10">
74 {image.name.split(".")[0]?.length > 10
75 ? image.name.split(".")[0].slice(0, 10) + "..."
76 : image.name.split(".")[0]}
77 </p>
78 <button
79 className="bg-black text-white px-3 py-1 text-sm rounded-md"
80 onClick={() => {
81 setUpdate(true);
82 setSelectedImage(image);
83 }}
84 >
85 Update
86 </button>
87 <button
88 onClick={() => {
89 onDeleteImage(image.id);
90 }}
91 className="bg-red-500 text-white px-3 py-1 rounded-md text-sm"
92 >
93 Delete
94 </button>
95 </div>
96 </div>
97 ))}
98 </div>
99 )}
100
101 {images?.length <= 0 && (
102 <p className="w-full text-orange-300 pt-5">No Images in Gallery.</p>
103 )}
104 </div>
105 );
106}
Here is what the Gallery
component above does:
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.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.UpdateImageModal
when "Update" is clicked and sets selectedImage
.Let's see how our app works when we try to update an image.
Update Image file info Demo
Here is what deleting an image looks like:
Delete an Image Demo
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.
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.
1// ./src/api/food/routes/favorite-food.ts
2
3// Create route to post favorite food
4export default {
5 routes: [
6 {
7 // Path defined with a URL parameter
8 method: "POST",
9 path: "/foods/favorite-food",
10 handler: "api::food.food.uploadImage",
11 },
12 ],
13};
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.
Locate the ./src/api/food/controllers/food.ts
and replace it with the following code:
1// ./src/api/food/controllers/food.ts
2
3/**
4 * food controller
5 */
6
7import { factories } from "@strapi/strapi";
8
9export default factories.createCoreController(
10 "api::food.food",
11 ({ strapi }) => ({
12 // create custom controller
13 async uploadImage(ctx) {
14 const contentType = strapi.contentType("api::food.food");
15
16 // get files
17 const { files } = ctx.request;
18
19 // name of input (key)
20 const file = files["anyName"];
21
22 // create image using the upload plugin
23 const createdFiles = await strapi.plugins.upload.services.upload.upload({
24 data: {
25 fileInfo: {
26 name: file["originalFilename"], // set the original name of the image
27 caption: "Caption", // give it a caption
28 alternativeText: "Alternative Text", // give it an alternative text
29 },
30 },
31 files: file,
32 });
33
34 // send response
35 ctx.status = 200;
36 ctx.body = {
37 data: createdFiles,
38 };
39 },
40 })
41);
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
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
.
Now, we want to perform what we have done using Next.js with Postman. Here are the things we will learn in this section.
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
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
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.
id
of the image you want to link. To do this, make a GET
request to the endpoint /api/upload/files
.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
.PUT
request to the endpoint /api/foods/{documentId}
, where documentId
is the doucment ID of the food entry.1{
2 "data": {
3 "cover": 13
4 }
5}
See the image below
Link an Image to an entry by its ID
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 via Postman
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:
1{
2 "fileInfo": {
3 "name": "new image",
4 "caption": "a caption",
5 "alternativeText": "the alternative text"
6 }
7}
See Image below:
Update an Image File Information in Strapi
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.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.