Skip to content
Zeha Irawan
Twitter

Integrating Strapi 5 with Next.js with CRUD example

Strapi, Next.js7 min read

How to integrate Strapi 5 with REST API with Next.js

We will use Youtube as an example to create a video upload, read, update, delete (CRUD) application.

mkdir youtube && cd youtube
npx create-strapi-app@latest backend
cd backend && npm run develop

And then in another terminal

npx create-next-app@latest frontend
cd frontend && npm run dev

Create video collection

Go to the Content-Type Builder, then create a new collection type with the display name of Video, you should get vdeio as singular API ID & videos as plural API ID.

Create video collection

Fields:

  • title => Short text
  • description => Long text
  • content => Single Media
  • user => Relation to Users & Permissions plugin
User has many videos relation

Now create comment collection with the following fields:

  • comment => Long text
  • user => User has many Comments Relation to Users & Permissions plugin
  • video => Video has many Comments Relation to Video collection

Configure Collection Permissions

Go to Users & Permission plugin => Roles => Public and enable all permission for Video & Comment collection.

Configure collection permission

You should not do this in production for security reason, this is just for demo purpose.

Creating a Video entry

Create video entry

Testing API Routes

curl -X GET "http://localhost:1337/api/videos?populate=*"

By default Strapi will not populate relations. You will need to explicitly populate it, read more about it in Strapi populate tutorial.

{
"id": 3,
"documentId": "c9gh48j8v6eaqej0jyodt80h",
"title": "Grass",
"description": "This is grass",
"createdAt": "2024-09-28T03:34:39.129Z",
"updatedAt": "2024-09-28T22:07:28.521Z",
"publishedAt": "2024-09-28T22:07:28.548Z",
"locale": null,
"content": {
"id": 1,
"documentId": "w1mqfpuajo2exi63aicvldri",
"name": "grass.mp4",
"alternativeText": null,
"caption": null,
"width": null,
"height": null,
"formats": null,
"hash": "grass_e543b2c814",
"ext": ".mp4",
"mime": "video/mp4",
"size": 42723.2,
"url": "/uploads/grass_e543b2c814.mp4",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2024-09-28T03:34:32.200Z",
"updatedAt": "2024-09-28T03:34:32.200Z",
"publishedAt": "2024-09-28T03:34:32.200Z",
"locale": null
},
"comments": [],
"thumbnail": {
"id": 2,
"documentId": "etcbjcyx1nq0orcxuvo9o5ay",
"name": "grass-thumbnail.png",
"alternativeText": null,
"caption": null,
"width": 1906,
"height": 1074,
"formats": {
"thumbnail": {
"name": "thumbnail_grass-thumbnail.png",
"hash": "thumbnail_grass_thumbnail_aa78312775",
"ext": ".png",
"mime": "image/png",
"path": null,
"width": 245,
"height": 138,
"size": 81.38,
"sizeInBytes": 81380,
"url": "/uploads/thumbnail_grass_thumbnail_aa78312775.png"
},
},
"hash": "grass_thumbnail_aa78312775",
"ext": ".png",
"mime": "image/png",
"size": 727.89,
"url": "/uploads/grass_thumbnail_aa78312775.png",
"previewUrl": null,
"provider": "local",
"provider_metadata": null,
"createdAt": "2024-09-28T22:04:00.609Z",
"updatedAt": "2024-09-28T22:04:00.609Z",
"publishedAt": "2024-09-28T22:04:00.610Z",
"locale": null
},
"localizations": []
}

Integrate Strapi with Next.js

GET request (Read)

Now let's display the video in the Next.js frontend.

Modify frontend/app/page.js

import Link from "next/link";
async function getVideos() {
try {
const res = await fetch("http://localhost:1337/api/videos?populate=*");
const videos = await res.json();
return videos;
} catch (error) {
console.error(error);
return { error: error.message };
}
}
export default async function Home() {
const response = await getVideos();
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold mb-4">Video Gallery</h1>
<Link href="/upload">
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">Upload Video</button>
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4">
{response.data.map((video) => (
<div key={video.id} className="bg-white rounded-lg shadow-md overflow-hidden">
<h2 className="text-xl font-semibold p-2">{video.title}</h2>
<video className="w-full" controls src={`http://localhost:1337/${video.content.url}`} />
<p className="text-gray-600 p-2">{video.description}</p>
</div>
))}
</div>
</div>
);
}

It will look like this

Video gallery

POST request (Create)

We will need a way to upload the video, create frontend/components/UploadComponent.jsx component.

In the example in their documentation in here Strapi uses documentId, but that doesn't work for me, so I need to use id instead.

Expected a valid Number, got gp1loi2d3mihdq0oiatl54a5

We don't need to set Content-Type to multipart/form-data, browser will automatically set it if you pass FormData. If you set it yourself, it will throw an error.

"use client";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
export const UploadComponent = () => {
const router = useRouter();
const [formData, setFormData] = useState({
title: "",
description: "",
video: null,
});
const createVideo = async (videoId) => {
try {
const response = await fetch("http://localhost:1337/api/videos", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
title: formData.title,
description: formData.description,
},
}),
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error:", error);
}
};
const handleSubmit = async (event) => {
event.preventDefault();
const video = await createVideo();
const videoId = video.data.id;
const videoFormData = new FormData();
videoFormData.append("files", formData.video);
videoFormData.append("ref", "api::video.video");
videoFormData.append("refId", videoId);
videoFormData.append("field", "content");
try {
const response = await fetch("http://localhost:1337/api/upload", {
method: "POST",
body: videoFormData,
});
if (!response.ok) {
throw new Error("Network response was not ok");
}
await response.json();
router.push(`/`);
} catch (error) {
console.error("Error:", error);
}
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
};
const handleFileChange = (e) => {
setFormData((prevData) => ({
...prevData,
video: e.target.files[0],
}));
};
return (
<form onSubmit={handleSubmit}>
<div className="mt-4">
<div>
<label htmlFor="title" className="block mb-2">
Title
</label>
<input
type="text"
id="title"
name="title"
className="border p-2 w-full"
value={formData.title}
onChange={handleChange}
/>
</div>
<div className="mt-4">
<label htmlFor="description" className="block mb-2">
Description
</label>
<textarea
id="description"
name="description"
className="border p-2 w-full"
rows="4"
value={formData.description}
onChange={handleChange}
></textarea>
<label htmlFor="video" className="block mb-2">
Video
</label>
<input
type="file"
id="video"
name="video"
className="border p-2 w-full"
accept="video/*"
onChange={handleFileChange}
/>
</div>
</div>
<button
type="submit"
className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
>
Upload
</button>
</form>
);
};

And then import it in frontend/app/upload/page.js

import { UploadComponent } from "@/components/UploadComponent";
export default async function UploadPage() {
return (
<div className="container mx-auto p-4">
<h1 className="text-3xl font-bold mb-4">This is Upload Page</h1>
<UploadComponent />
</div>
);
}
Upload video page

PUT request (Update)

Modify frontend/components/UploadComponent.jsx to accept isEditing and existingVideo prop.

"use client";
import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
const UploadComponent = ({ isEditing = false, existingVideo = null }) => {
const router = useRouter();
const [formData, setFormData] = useState({
title: "",
description: "",
video: null,
});
useEffect(() => {
if (isEditing && existingVideo) {
setFormData({
title: existingVideo.title,
description: existingVideo.description,
});
}
}, [isEditing, existingVideo]);
const updateVideo = async (videoId) => {
try {
const response = await fetch(
`http://localhost:1337/api/videos/${videoId}`,
{
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
data: {
title: formData.title,
description: formData.description,
content: { id: existingVideo.content.id },
},
}),
},
);
if (!response.ok) {
throw new Error("Network response was not ok");
}
const data = await response.json();
return data;
} catch (error) {
console.error("Error:", error);
}
};
const handleSubmit = async (event) => {
event.preventDefault();
if (isEditing) {
await updateVideo(existingVideo?.documentId);
return;
}
// ...rest of the code
};
return (
<form onSubmit={handleSubmit}>
// ...existing code
{!isEditing && (
<>
<label htmlFor="video" className="block mb-2">
Video
</label>
<input
type="file"
id="video"
name="video"
className="border p-2 w-full"
accept="video/*"
onChange={handleFileChange}
/>
</>
)}
</div>
</div>
<button
type="submit"
className="mt-4 bg-blue-500 text-white px-4 py-2 rounded"
>
{isEditing ? "Update" : "Upload"}
</button>
</form>
);
};
export default UploadComponent;

On my first try, I missed that you have to pass the unmodified field in the PUT request body, and that resulted video field being empty.

That's why we need to do this content: { id: existingVideo.content.id }

https://docs.strapi.io/dev-docs/api/rest#update

Create frontend/app/edit/[videoID]/page.jsx

"use client";
import { useParams } from "next/navigation";
import Link from "next/link";
import UploadComponent from "../../../components/UploadComponent";
import { useEffect, useState } from "react";
export default function EditPage() {
const { videoID } = useParams();
const [video, setVideo] = useState(null);
const getVideo = async () => {
const response = await fetch(`http://localhost:1337/api/videos/${videoID}?populate=*`);
const data = await response.json();
return data;
};
useEffect(() => {
const fetchVideo = async () => {
const video = await getVideo();
setVideo(video.data);
};
fetchVideo();
}, []);
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold mb-4">{`Editing ${video?.title}`}</h1>
<div className="flex gap-4">
<Link href="/">
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">
Home
</button>
</Link>
<Link href="/upload">
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">
Upload Video
</button>
</Link>
</div>
</div>
<UploadComponent isEditing={true} existingVideo={video} />
</div>
);
}

DELETE request (Delete)

Create frontend/app/manage/page.js

"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
export default function ManagePage() {
const [videos, setVideos] = useState([]);
const getVideos = async () => {
const res = await fetch("http://localhost:1337/api/videos?populate=*");
const videos = await res.json();
setVideos(videos.data);
};
useEffect(() => {
getVideos();
}, []);
const deleteVideo = async (documentId) => {
await fetch(`http://localhost:1337/api/videos/${documentId}`, {
method: "DELETE",
});
setVideos(videos.filter((video) => video.documentId !== documentId));
};
return (
<div className="container mx-auto p-4">
<div className="flex justify-between items-center mb-4">
<h1 className="text-3xl font-bold mb-4">Manage</h1>
<div className="flex gap-4">
<Link href="/">
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">
Homepage
</button>
</Link>
<Link href="/upload">
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">
Upload Video
</button>
</Link>
</div>
</div>
<div className="flex flex-col gap-12">
{videos.map((video) => (
<div key={video.id}>
<h2 className="text-xl font-bold">{video.title}</h2>
<div className="flex gap-4 mt-4">
<Link href={`/edit/${video.documentId}`}>
<button className="bg-blue-500 text-white px-4 py-2 rounded-md">
Edit
</button>
</Link>
<button
onClick={() => deleteVideo(video.documentId)}
className="bg-red-500 text-white px-4 py-2 rounded-md"
>
Delete
</button>
</div>
</div>
))}
</div>
</div>
);
}

We can now delete and edit the video from the manage page.

Manage page