Integrating Strapi 5 with Next.js with CRUD example
— Strapi, Next.js — 7 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 youtubenpx create-strapi-app@latest backendcd backend && npm run develop
And then in another terminal
npx create-next-app@latest frontendcd 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.
Fields:
- title => Short text
- description => Long text
- content => Single Media
- user => Relation to Users & Permissions plugin
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.
You should not do this in production for security reason, this is just for demo purpose.
Creating a 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
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> );}
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.