Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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(https://strapi.io/five, 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./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;
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
// .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>
);
}
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
2
3
@tailwind base;
@tailwind components;
@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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
"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
){}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// ./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}`);
}
};
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// ./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,
};
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// .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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// ./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,
};
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: "localhost"
}
]
}
};
export 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ./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,
};
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// ./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>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// .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>(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 /> : <LinkByGallery />}
</div>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// ./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<FoodEntry[]>([]); // 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 (
<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 uploaded images so as update anyone.
</span>
</div>
{foods?.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-2">
{foods?.map((food) => (
<div key={food.id} className="group h-full border">
<div className="h-full">
<p className="p-2 font-bold border-b flex flex-col items-center justify-center bg-black text-white">
<span>{food?.name}</span>
</p>
<div className="h-[200px] w-[400]px">
{food?.cover?.url ? (
<Image
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"
/>
) : (
<div className="h-full flex flex-col justify-center items-center text-sm text-[#71717a]">
No linked image
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
{foods?.length <= 0 && <p>No foods and images linked</p>}
</div>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// ./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",
};
} 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.
UploadAtEntryCreation
ComponentIn order to use the server action above, we have to modify the ./src/components/UploadAtEntryCreation.tsx
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// ./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<HTMLFormElement>(null);
useEffect(() => {
formRef?.current?.reset();
toast.success(state?.uploadSuccess);
}, [state]);
return (
<form
ref={formRef}
action={formAction}
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">
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>
{state?.uploadError ? (
<span className="text-red-500">{state?.uploadError}</span>
) : null}
<SubmitButton title="Upload" />
</div>
</div>
</form>
);
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// ./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",
};
} catch (error: any) {
return {
uploadError: error.message,
uploadSuccess: null,
};
}
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// ./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<void>;
}
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 (
<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">
<div className="w-fit h-fit lg:w-[500px] border flex flex-col bg-white ">
<div className="flex justify-end p-2">
<button
onClick={() => {
closeModal();
}}
className="relative w-fit border bg-black text-white px-3 py-1 rounded-md"
>
close
</button>
</div>
<form className="p-10" action={formAction}>
<div className="w-full my-5">
<p className="text-base lg:text-lg">
Update File Infos: {imageSelected.name.split(".")[0]}{" "}
</p>
<span className="text-sm text-[#71717a]">
Update a specific image in your application.
</span>
</div>
<div className="flex flex-col pt-5 gap-y-7">
<div className="flex flex-col space-y-2">
<label htmlFor="cover">Name</label>
<input
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"
/>
<span className="text-sm text-[#71717a]">Type in a new name</span>
</div>
<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:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
// ./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<ImageEntry[]>([]);
const [selectedImage, setSelectedImage] = useState<ImageEntry | null>(null);
const [update, setUpdate] = useState<boolean>(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 (
<div className=" w-full divide-y">
<div>
{update ? (
<UpdateImageModal
closeModal={closeModal}
imageSelected={selectedImage as ImageEntry}
handleFetchImages={handleFetchImages}
/>
) : null}
</div>
<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:
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
2
3
4
5
6
7
8
9
10
11
12
13
// ./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.
Locate the ./src/api/food/controllers/food.ts
and replace it with the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// ./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
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
3
4
5
{
"data": {
"cover": 13
}
}
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
3
4
5
6
7
{
"fileInfo": {
"name": "new image",
"caption": "a caption",
"alternativeText": "the alternative text"
}
}
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.