DEV Community

Zeha Irawan
Zeha Irawan

Posted on • Originally published at zehairawan.com

Integrating Strapi 5 with Next.js with CRUD example

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.

You can also find the full source code on github.

mkdir youtube && cd youtube
npx create-strapi-app@latest backend
cd backend && npm run develop
Enter fullscreen mode Exit fullscreen mode

And then in another terminal

npx create-next-app@latest frontend
cd frontend && npm run dev
Enter fullscreen mode Exit fullscreen mode

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=*"
Enter fullscreen mode Exit fullscreen mode

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": []
        }
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
};

Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;

Enter fullscreen mode Exit fullscreen mode

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.

Read more on
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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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

Manage page

Top comments (0)