Soft deletion is a crucial technique to prevent data from being permanently erased in a database. Both MySQL and PostgreSQL offer support for this functionality. In this tutorial, we're going to walk you through how to craft a custom soft delete capability using Strapi.js. Additionally, we'll dive into integrating a user interface utilizing the newly released Next.Js 14 App Router. . By the conclusion of this guide, you'll have developed a fully operational system for organizing articles, equipped with its own recycle bin feature, similar to the one shown below.
The following are required to follow along with this tutorial:
Soft deletion is an approach where data or records are not permanently removed from a database upon deletion. Unlike immediate and irreversible removal, soft deletion entails marking records as inactive, hidden, or logically deleted. This method allows for potential recovery or restoration.
Here are some key reasons to consider the soft delete strategy:
We will enhance our soft delete functionality by adding two fields to our collection: deleted and deleted_at. The deleted field will be a boolean, indicating whether a record has been soft deleted. While this field is sufficient for basic soft delete logic, the addition of deleted_at, a date field, serves a crucial purpose.
The deleted_at field is particularly useful for scenarios where there's a need to clear the recycle bin or trash periodically, such as after 30 days. To automate this process, we will set up a cron job in Strapi, which we will explore in more detail later in this tutorial.
After having creating a Strapi project that we will call my-project in this tutorial, proceed to create a collection named Article with these specific fields and their respective types:
These fields are designed to facilitate effective management and tracking of articles, particularly in implementing soft deletion functionality.
A controller contains methods or actions that are executed when a request is made to a specific route, such as the /api/articles route in our case.
We need to develop controllers for various operations: performing a soft delete, executing a permanent delete, retrieving articles from the recycle bin, emptying the bin, recovering an article, finding articles, and fetching only those articles that have not been soft deleted.
Replace the code inside the src/api/controllers/article.js
file with the following code:
1// path: ./src/api/controllers/article.js
2// @ts-nocheck
3"use strict";
4/**
5 * article controller
6 */
7const { createCoreController } = require("@strapi/strapi").factories;
8const moment = require("moment");
9module.exports = createCoreController("api::article.article", ({ strapi }) => ({
10 async softDelete(ctx) {
11 const { id } = ctx.params;
12 // get the article
13 const article = await strapi.service("api::article.article").findOne(id);
14 // if article doesn't exist
15 if (!article) {
16 return ctx.notFound("Article does not exist", {
17 details: "This article was not found. Check article id please.",
18 });
19 }
20 // get current date
21 const currentDate = moment(Date.now()).format("YYYY-MM-DD");
22 // update article
23 const entity = await strapi.service("api::article.article").update(id, {
24 data: {
25 deleted: true,
26 deleted_at: currentDate,
27 },
28 });
29 const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
30 return this.transformResponse(sanitizedEntity);
31 },
32}));
In the code above, we created a custom action called softDelete()
. This action retrieves an article by its id
. It gets the current date and updates the article by setting its deleted field to a true value and deleted_at
to the current date. Finally, it returns the sanitized article as a response.
Install the
moment
library by running the commandnpm i moment
in your terminal.
This action will allow a user to delete an article when they don’t wish to have it anymore in the recycle bin or trash. Inside the src/api/controllers/article.js
file, add the following action after the softDelete()
action.
1// path: ./src/api/controllers/article.js
2async permanentDelete(ctx) {
3 const { id } = ctx.params;
4 // get article
5 const article = await strapi.service("api::article.article").findOne(id);
6 // if no article
7 if (!article) {
8 const sanitizedEntity = await this.sanitizeOutput(article, ctx);
9 return this.transformResponse(sanitizedEntity);
10 }
11 //permanently delete article
12 const entity = await strapi.service("api::article.article").delete(id);
13 const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
14 return this.transformResponse(sanitizedEntity);
15}
In the code above, the action permanentDelete()
retrieves an article and deletes it permanently from the Article
collection.
This action should fetch and return all the articles that have been soft deleted. Inside the src/api/controllers/article.js
file, add the following action after the permanentDelete()
action.
1// path: ./src/api/controllers/article.js
2async getBin(ctx) {
3 const bin = await strapi.entityService.findMany("api::article.article", {
4 filters: {
5 deleted: true,
6 },
7 });
8
9 const sanitizedEntity = await this.sanitizeOutput(bin, ctx);
10 return this.transformResponse(sanitizedEntity);
11}
Using the entityService.findMany()
function, we can get all articles and filter those whose deleted
fields are true
. It will return all articles that have been soft deleted.
This action allows a user to recover an article in the recycle bin. Inside the src/api/controllers/article.js
file, add the following action after the getBin()
action.
1// path: ./src/api/controllers/article.js
2async recover(ctx) {
3 const { id } = ctx.params;
4 const article = await strapi.service("api::article.article").findOne(id);
5
6 if (!article) {
7 const sanitizedEntity = await this.sanitizeOutput(article, ctx);
8 return this.transformResponse(sanitizedEntity);
9 }
10
11 // update article
12 const entity = await strapi.service("api::article.article").update(id, {
13 data: {
14 deleted: false,
15 deleted_at: null,
16 },
17 });
18
19 const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
20 return this.transformResponse(sanitizedEntity);
21}
In the code above, we retrieved an article and modified its records by setting its deleted
field to false
and deleted_at
to null
.
A user can decide to empty their trash or recycle bin. This function will delete all records whose deleted
field is true
. That way, the system will permanently delete all soft-deleted articles simultaneously. Inside the src/api/controllers/article.js
file, add the following action after the recover()
action.
1// path: ./src/api/controllers/article.js
2async emptyTrash(ctx) {
3 const softDeletedArticles = await strapi.db
4 .query("api::article.article")
5 .deleteMany({
6 where: {
7 deleted: true,
8 },
9 });
10
11 const sanitizedEntity = await this.sanitizeOutput(result, ctx);
12
13 return this.transformResponse(sanitizedEntity);
14}
In the code above, we created the emptyTrash()
action. Using the deleteMany()
function of the database query, we retrieved all articles that had been soft deleted and finally deleted them from the Article
collection of our database.
When a user searches for all articles, what should be returned are articles that haven’t been soft deleted. If we don’t modify this action, it will return any article, whether soft deleted or not.
Inside the src/api/controllers/article.js
file, add the following action after the emptyTrash()
action.
1// path: ./src/api/controllers/article.js
2function find(ctx) {
3 const articles = await strapi.entityService.findMany("api::article.article", {
4 filters: {
5 deleted: false,
6 },
7 });
8 const sanitizedEntity = await this.sanitizeOutput(articles, ctx);
9
10 return this.transformResponse(sanitizedEntity);
11}
In the code above, we made sure that when articles are requested, it is those that haven’t been soft deleted that the server can return.
Just as a user should see only articles that have not been soft deleted when they request all articles, they should only see an article that has not been soft deleted.
Inside the src/api/controllers/article.js
file, add the following action after the find()
action.
1// path: ./src/api/controllers/article.js
2function findOne(ctx) {
3 const { id } = ctx.params;
4 const article = await strapi.service("api::article.article").findOne(id);
5
6 if (!article || article.deleted) {
7 article = null;
8 }
9
10 const sanitizedEntity = await this.sanitizeOutput(article, ctx);
11 return this.transformResponse(sanitizedEntity);
12}
In the code above, we replace the original findOne()
action by allowing it to return only an article that hasn’t been soft deleted.
We already have the articles router in the src/api/routes/article.js
file. Furthermore, we need to create two more custom routers. One is for the bin, and the other for deletion. Inside the src/api/routes/
folder, create the files 01-bin.js
and 02-delete.js
.
NOTE: We named the custom routes with prefixes
01
and02
to ensure routes are loaded alphabetically. This will allow these custom routes to be reached before the core router.
Inside the src/api/routes/01-bin.js
file, add the following code:
1// path: ./src/api/routes/01-bin.js
2module.exports = {
3 routes: [
4 {
5 method: "GET",
6 path: "/articles/bin",
7 handler: "article.getBin",
8 },
9 {
10 method: "PUT",
11 path: "/articles/bin/:id/recover",
12 handler: "article.recover",
13 },
14
15 {
16 method: "DELETE",
17 path: "/articles/bin/empty",
18 handler: "article.emptyTrash",
19 },
20 ],
21};
In the code above, we created a custom router for routes /articles/bin
.
In the first route, we specified that GET
requests to this route should invoke the getBin()
action of the article controller function we created previously.
The second route specifies that any PUT
request to the /articles/bin/:id/recover
should invoke the recover()
action of the article controller.
Finally, the final route /articles/bin/empty
will invoke the emptyTrash()
action of the article controller.
Inside the src/api/routes/01-delete.js
file, add the following code:
1// path: ./src/api/routes/01-delete.js
2module.exports = {
3 routes: [
4 {
5 method: "PUT",
6 path: "/articles/:id/soft-delete",
7 handler: "article.softDelete",
8 },
9 {
10 method: "DELETE",
11 path: "/articles/:id/permanent-delete",
12 handler: "article.permanentDelete",
13 },
14 ],
15};
In the code above, the first route specifies that PUT
requests to /articles/:id/soft-delete
should invoke the function softDelete()
.
The second route specifies that DELETE
requests to /articles/:id/permanent-delete
should invoke the permanentDelete()
action.
As noted earlier, soft-deleted articles also have the date field deleted_at
. It is so that we can automatically delete any article in the trash that has exceeded 30 days since its soft deletion.
Locate the config
folder, create the file cron-tasks.js
and add the following code:
1//path: ./config/cron-tasks.js
2const moment = require("moment");
3module.exports = {
4 /**
5 * CRON JOB
6 * Runs for every day
7 */
8
9 myJob: {
10 task: async ({ strapi }) => {
11 const thirtyDaysAgo = moment().subtract(30, "days").format("YYYY-MM-DD");
12 // Add your own logic here (e.g. send a queue of email, create a database backup, etc.).
13 await strapi.db.query("api::article.article").deleteMany({
14 where: {
15 deleted_at: {
16 $lt: thirtyDaysAgo,
17 },
18 },
19 });
20 },
21 options: {
22 rule: "0 0 * * *", // run every day at midnight
23 },
24 },
25};
In the code above, we set up a cron job that will run every day at midnight to delete articles that have stayed in the trash for more than 30 days.
In order for this cron job to run, we need to add it to our server.js
file which is also inside the config
folder of our app. Locate the server.js
file and add the following code:
1// path: ./config/server.js
2const cronTasks = require("./cron-tasks");
3
4module.exports = ({ env }) => ({
5 host: env("HOST", "0.0.0.0"),
6 port: env.int("PORT", 1337),
7 app: {
8 keys: env.array("APP_KEYS"),
9 },
10 webhooks: {
11 populateRelations: env.bool("WEBHOOKS_POPULATE_RELATIONS", false),
12 },
13 cron: {
14 enabled: true,
15 tasks: cronTasks,
16 },
17});
In the code above, we imported the cron job we have created and enabled it. It will run every single day at midnight.
Once we have set up our Strapi server, we have to set up the UI of our application.
For the frontend, we'll use Next.js
We will make use of Next.js new app router. Run the command below to install Next.js.
npx create-next-app@latest
Make sure to choose the following:
After the installation, cd
into the Next.js project and run the command below to start the application:
npm run dev
We should see the following displayed on our browser:
Next, we will install the following dependencies for our project:
npm i react-icons react-toastify
Inside the app
folder, create a folder utils
. Inside the new utils
folder, create a new file urls.ts
and add the following code:
1// path: ./app/utils/urls.ts
2export const serverURL = "http://127.0.0.1:1337/api"
This represents the URL of our Strapi API. We will make use of it for every request to our Strapi server.
Inside the app
folder, create a new folder components
. Inside this new folder, create the following files AddArticleModal.tsx
, SearchBar.tsx
and Sidebar.tsx
and a folder called Buttons
.
Inside the /app/components/AddArticleModal.tsx
file, add the following code:
1// path : /app/components/AddArticleModal.tsx
2"use client";
3
4import { useState } from "react";
5import { GoPlus } from "react-icons/go";
6import { MdClear } from "react-icons/md";
7import { serverURL } from "../utils/urls";
8import { useRouter } from "next/navigation";
9import { toast } from "react-toastify";
10
11export interface IFormInputs {
12 name: string;
13 content: string;
14}
15
16export default function AddArticleModal() {
17 const [open, setOpen] = useState<boolean>(false);
18 const [newArticle, setNewArticle] = useState<IFormInputs>({
19 name: "",
20 content: "",
21 });
22 const [error, setError] = useState<string>("");
23
24 const router = useRouter();
25
26 const handleModal = () => {
27 setOpen((prev) => !prev);
28 setNewArticle({ name: "", content: "" });
29 setError("");
30 };
31
32 const handleInputChange = (
33 e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
34 ) => {
35 setError("");
36 const { value, name } = e.target;
37 setNewArticle((prev: IFormInputs) => ({ ...prev, [name]: value }));
38 };
39
40 const handleCreateArticle = async (e: React.FormEvent<HTMLFormElement>) => {
41 e.preventDefault();
42 const { name, content } = newArticle;
43
44 if (!name || !content) {
45 setError("All fields are required!");
46 return;
47 }
48
49 try {
50 const response = await fetch(`${serverURL}/articles`, {
51 headers: {
52 "Content-Type": "application/json",
53 },
54 body: JSON.stringify({ data: newArticle }),
55 method: "POST",
56 });
57
58 const result = await response.json();
59 if (result.data) {
60 setOpen(false);
61 setNewArticle({ name: "", content: "" });
62 toast.success("Article created successfully!");
63 router.refresh();
64 return;
65 }
66 const error = result.error;
67 if (error.name === "ValidationError") {
68 toast.error(
69 `An article with the name "${newArticle.name}" already exists!`,
70 );
71 } else {
72 toast.error("Something went wrong");
73 }
74 setNewArticle({ name: "", content: "" });
75 } catch (error: unknown) {
76 if (error instanceof Error) setError(error?.message);
77 setNewArticle({ name: "", content: "" });
78 }
79 };
80
81 return (
82 <div>
83 <button
84 onClick={handleModal}
85 className=" py-4 px-5 w-28 border flex items-center justify-between shadow-lg bg-white hover:bg-secondary rounded-lg my-5 transition-all duration-500"
86 >
87 <GoPlus size={24} />
88 <span className="text-[14px] text-black1">New</span>
89 </button>
90 <div
91 className={`${
92 open ? " visible " : " invisible "
93 } h-screen fixed left-0 w-screen top-0 flex flex-col justify-center items-center bg-black bg-opacity-90 z-50 transition-all `}
94 >
95 <div className="bg-white p-10 w-1/2 rounded-lg">
96 <form
97 onSubmit={handleCreateArticle}
98 className="w-full py-5 flex flex-col space-y-5"
99 >
100 <p className="text-center font-bold text-[20px]">
101 Create an Article
102 </p>
103 <p className="text-red-500 text-center text-sm">{error}</p>
104 <div>
105 <label>Name of Article</label>
106 <input
107 onChange={handleInputChange}
108 value={newArticle?.name}
109 name="name"
110 type="text"
111 placeholder="Article Name"
112 className="w-full border p-2 my-2 text-black2"
113 />
114 </div>
115 <div>
116 <label>Content of Article</label>
117 <textarea
118 onChange={handleInputChange}
119 value={newArticle?.content}
120 name="content"
121 placeholder="Article Content"
122 className="w-full border p-2 my-2 text-black1"
123 ></textarea>
124 </div>
125 <button
126 type="submit"
127 className="rounded-full hover:bg-secondary w-fit px-5 py-3 shadow-lg"
128 >
129 Create Article
130 </button>
131 </form>
132 </div>
133 <button
134 onClick={handleModal}
135 className="text-white absolute top-20 right-1/2"
136 >
137 <MdClear size={24} />
138 </button>
139 </div>
140 </div>
141 );
142}
The code above is the AddArticleModal
client component. This component allows us to create new articles through a modal, with client-side validation and interaction with a server API. It makes a POST
request to the /articles
route of our server.
This will serve as a way to search for articles. Inside the /app/components/SearchBar.tsx
file, add the following code:
1// path: /app/components/SearchBar.tsx
2"use client";
3
4import Link from "next/link";
5import React, { useEffect, useState } from "react";
6import { FaCircleInfo, FaRecycle } from "react-icons/fa6";
7import { GoSearch } from "react-icons/go";
8import { MdClear } from "react-icons/md";
9import { serverURL } from "../utils/urls";
10
11export interface IArticle {
12 id: string;
13 attributes: {
14 name: string;
15 };
16}
17export default function SearchBar() {
18 const [keyword, setKeyword] = useState<string>("");
19 const [articles, setArticles] = useState<IArticle[]>([]);
20 const [error, setError] = useState<string>("");
21
22 const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
23 setKeyword(e.target.value);
24 };
25
26 useEffect(() => {
27 const getArticles = async () => {
28 try {
29 const response = await fetch(`${serverURL}/articles`, {
30 method: "GET",
31 });
32 const { data } = await response.json();
33 setArticles(data);
34 } catch (error: unknown) {
35 if (error instanceof Error) setError(error.message);
36 }
37 };
38 getArticles();
39 }, []);
40
41 return (
42 <div className="relative">
43 <div className="flex items-center justify-between space-x-10">
44 <div className="w-3/4 relative">
45 <div className="input-container focus-within:bg-white focus-within:shadow-lg bg-primary w-full flex items-center rounded-full px-5 h-14">
46 <GoSearch size={24} />
47 <input
48 onChange={handleSearch}
49 value={keyword}
50 type="text"
51 placeholder="Search"
52 className="ml-3 bg-transparent outline-none border-none w-full capitalize"
53 />
54 <button
55 onClick={() => {
56 setKeyword("");
57 }}
58 >
59 <MdClear size={24} />
60 </button>
61 </div>
62 {keyword ? (
63 <div className=" rounded-b-xl h-[250px] shadow-lg bg-white pb-20 absolute z-50 w-full">
64 <div className="py-3 bg-white overflow-y-scroll absolute w-full h-[200px] p-5">
65 {error ? (
66 <div className="text-red-500 flex items-center justify-center shadow-2xl p-3 rounded-md">
67 <FaCircleInfo size={30} />
68 <span className="ml-2">{error}</span>
69 </div>
70 ) : (
71 <div>
72 {articles.find((article) =>
73 article.attributes.name
74 .toLowerCase()
75 .includes(keyword.trim().toLowerCase()),
76 ) ? null : (
77 <div className="text-center p-20">
78 <p className="text-red-500">No article found!</p>
79 </div>
80 )}
81 {keyword &&
82 articles
83 .filter((article) => {
84 if (keyword.trim() === "") {
85 return article;
86 }
87 if (
88 article?.attributes?.name
89 ?.toLowerCase()
90 .includes(keyword.trim().toLowerCase())
91 ) {
92 return article;
93 }
94 return null;
95 })
96 .map((article) => (
97 <Link
98 href={`/articles/${article.id}`}
99 key={article.id}
100 className="p-5 bg-primary hover:bg-secondary inset-1 flex items-center justify-betwee py-2 px-3 w-full "
101 >
102 {article?.attributes?.name}
103 </Link>
104 ))}
105 </div>
106 )}
107 </div>
108 </div>
109 ) : null}
110 </div>
111
112 <div className="w-1/4 flex justify-end">
113 <Link href="/bin" className="flex">
114 <FaRecycle size={24} />
115 <span className="ml-3">Recycle Bin</span>
116 </Link>
117 </div>
118 </div>
119 </div>
120 );
121}
This will serve as our side bar. It will also import the AddArticleModal
component. Add the following code inside the /app/components/SideBar.tsx
file:
1// path: /app/components/SideBar.tsx
2import Link from "next/link";
3import { TfiWrite } from "react-icons/tfi";
4import { SiAppwrite } from "react-icons/si";
5import { FiTrash2 } from "react-icons/fi";
6import AddArticleModal from "./AddArticleModal";
7import { serverURL } from "../utils/urls";
8
9const getArticles = async () => {
10 try {
11 const response = await fetch(`${serverURL}/articles`, {
12 method: "GET",
13 cache: "no-cache",
14 });
15 return response.json();
16 } catch (error) {
17 console.log(error);
18 }
19};
20
21export default async function Sidebar() {
22 const result = await getArticles();
23 const noOfArticles = result?.data?.length;
24
25 return (
26 <div className=" font-poppins font-thin ">
27 <div className="fixed top-0 z-[30] h-full visible sm:w-[287px] bg-primary p-5">
28 <Link href="/" className="flex">
29 <TfiWrite size={40} />
30 <span className="text-[22px] ml-3 font-poppins">Article System</span>
31 </Link>
32
33 <AddArticleModal />
34 <div className="flex flex-col space-y-3 text-black2 text-[14px]">
35 <Link
36 href="/articles"
37 className="hover:bg-secondary px-5 py-1 rounded-full flex items-center"
38 >
39 <SiAppwrite size={24} />
40 <span className="ml-3">My Articles</span>
41 </Link>
42 <Link
43 href="/bin"
44 className="hover:bg-secondary px-5 py-1 rounded-full flex items-center "
45 >
46 <FiTrash2 size={24} />
47 <span className="ml-3">Trash</span>
48 </Link>
49 </div>
50 <div className="">
51 <div className="mt-20 w-fit border border-black rounded-full px-5 py-1">
52 <span className="text-blue-500 text-[14px] font-extrabold">
53 Total Articles ({noOfArticles})
54 </span>
55 </div>
56 </div>
57 </div>
58 </div>
59 );
60}
The code above represents the SideBar
server component. It serves as a fixed sidebar displaying our article management system. It includes links to the home page, a modal for adding articles ( the AddArticleModal
component ), links to "My Articles" and the "Trash" pages, which we will create soon, and a display of the total number of articles fetched from the server using a getArticles()
function.
Inside the /app/components/buttons
folder, create the files BackButton.tsx
, DeleteArticle.tsx
, EmptyTrashButton.tsx
, RecoverArticle.tsx
and PermanentDeleteArticle.tsx
. Inside the BackButton.tsx
, add the following code:
1// path: /app/components/buttons/BackButton.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { RiArrowGoBackFill } from "react-icons/ri";
6export default function BackButton() {
7 const router = useRouter();
8 const handleBack = () => {
9 router.back();
10 };
11 return (
12 <button
13 onClick={handleBack}
14 className="text-black1 hover:bg-primary px-5 my-3 py-2 bg-white shadow-md primary-button-curved w-fit flex items-center text-lg"
15 >
16 <span className="">
17 <RiArrowGoBackFill />
18 </span>
19 <span className="ml-1 font-barlow text-[14px]">Go Back</span>
20 </button>
21 );
22}
The code above represents the BackButton
button component which serves as a navigational button to go back to the previous page.
Now, add the following code Inside the DeleteArticle
file.
1// path : /app/components/buttons/DeleteArticle.tsx
2"use client";
3
4import { AiOutlineDelete } from "react-icons/ai";
5import { toast } from "react-toastify";
6import { serverURL } from "../../utils/urls";
7import { useRouter } from "next/navigation";
8
9export interface Props {
10 articleId: string;
11}
12export default function DeleteArticleButton({ articleId }: Props) {
13 const router = useRouter();
14
15 const handleSoftDeleteArticle = async () => {
16 try {
17 const response = await fetch(
18 `${serverURL}/articles/${articleId}/soft-delete`,
19 {
20 method: "PUT",
21 },
22 );
23 const result = await response.json();
24
25 if (result.data) {
26 toast.success("Article Deleted!");
27 router.refresh();
28 } else {
29 toast.error("Something went wrong!");
30 }
31 } catch (error: unknown) {
32 if (error instanceof Error) toast.error(error.message);
33 }
34 };
35
36 return (
37 <button onClick={handleSoftDeleteArticle} className="ml-3">
38 <AiOutlineDelete size={24} color="red" />
39 </button>
40 );
41}
The code above represents a button for soft-deleting an article. The button triggers the handleSoftDeleteArticle
function when clicked. This function sends a PUT
request to the server to perform a soft delete on the specified article using its articleId
. If the operation is successful, a success toast notification is displayed, and the router.refresh()
function to refresh the page. In the event of an error, the app shows a toast.
Inside the EmptyTrashButton.tsx
file, add the following:
1// path : /app/components/buttons/EmptyTrashButton.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { toast } from "react-toastify";
6import { serverURL } from "../../utils/urls";
7
8export default function EmptyTrashButton() {
9 const router = useRouter();
10
11 const handleEmptyTrash = async () => {
12 try {
13 const response = await fetch(`${serverURL}/articles/bin/empty`, {
14 method: "DELETE",
15 });
16 const result = await response.json();
17
18 if (result.data) {
19 toast.success("Trash emptied successfully!");
20 router.refresh();
21 } else {
22 toast.error("Something went wrong!");
23 }
24 } catch (error: unknown) {
25 if (error instanceof Error) toast.error(error.message);
26 }
27 };
28 return (
29 <button onClick={handleEmptyTrash} className="text-red-500 ml-5 underline">
30 Empty Trash
31 </button>
32 );
33}
EmptyTrashButton
in the code above represents a button for emptying the trash or recycle bin. The button, when clicked, invokes the handleEmptyTrash()
function, which sends a DELETE
request to the server to empty the trash. After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using the router.refresh()
function. In the event of an error, the system shows a toast error.
Inside the PermanentDeleteArticle.tsx
file, add the following code:
1// path : ./app/components/buttons/PermanentDeleteArticle.tsx
2"use client";
3
4import { useRouter } from "next/navigation";
5import { serverURL } from "../../utils/urls";
6import { toast } from "react-toastify";
7
8export interface Props {
9 articleId: string;
10}
11
12export default function PermanentDeleteArticle({ articleId }: Props) {
13 const router = useRouter();
14
15 const handleDelParmanently = async () => {
16 try {
17 // /articles/:id/permanent-delete
18 const response = await fetch(
19 `${serverURL}/articles/${articleId}/permanent-delete`,
20 {
21 method: "DELETE",
22 },
23 );
24 const result = await response.json();
25
26 if (result.data) {
27 toast.success("Article Deleted Permanently");
28 router.refresh();
29 } else {
30 toast.error("Something went wrong!");
31 }
32 } catch (error: unknown) {
33 if (error instanceof Error) toast.error(error.message);
34 }
35 };
36
37 return (
38 <button
39 onClick={handleDelParmanently}
40 className="border p-3 rounded-full w-40 hover:bg-red-500 shadow-lg hover:text-white"
41 >
42 Delete Permanently
43 </button>
44 );
45}
The code above represents a button for permanently deleting a specific article. The button is associated with the handleDelParmanently()
function. It sends a DELETE
request to the server to perform a permanent delete on the article specified by articleId
. After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using the router.refresh()
function.
Inside the RecoverArticle.tsx
file, add the following:
1// path: app/components/buttons/RecoverArticle.tsx
2
3"use client";
4
5import { FaRecycle } from "react-icons/fa6";
6import { useRouter } from "next/navigation";
7import { toast } from "react-toastify";
8import { serverURL } from "../../utils/urls";
9
10export interface Props {
11 articleId: string;
12}
13
14export default function RecoverArticle({ articleId }: Props) {
15 const router = useRouter();
16
17 const handleRecoverArticle = async () => {
18 try {
19 const response = await fetch(
20 `${serverURL}/articles/bin/${articleId}/recover`,
21 {
22 method: "PUT",
23 },
24 );
25 const result = await response.json();
26
27 if (result.data) {
28 toast.success("Article Recovered!");
29 router.refresh();
30 } else {
31 toast.error("Something went wrong!");
32 }
33 } catch (error: unknown) {
34 if (error instanceof Error) toast.error(error.message);
35 }
36 };
37 return (
38 <button
39 onClick={handleRecoverArticle}
40 className="ml-3 border p-3 rounded-full w-40 flex items-center justify-center hover:bg-secondary shadow-lg"
41 >
42 <FaRecycle />
43 <span className="ml-3">Recover</span>
44 </button>
45 );
46}
The code above represents a button for recovering (restoring) a previously deleted article. The button is associated with the handleRecoverArticle
function, which sends a PUT
request to the server to recover the article specified by articleId
from the bin (trash). After the operation, the code checks the response, displays a success toast if it was successful, and refreshes the page using router.refresh()
.
Inside the app/layout.tsx
file, add the following code to define the layout of this project.
1// path: app/layout.tsx
2import type { Metadata } from "next";
3import { Poppins } from "next/font/google";
4import "./globals.css";
5import Sidebar from "./components/Sidebar";
6import { ToastContainer } from "react-toastify";
7import "react-toastify/dist/ReactToastify.css";
8
9const poppins = Poppins({
10 weight: ["400", "700"],
11 style: ["normal", "italic"],
12 subsets: ["latin"],
13 display: "swap",
14 variable: "--font-poppins",
15});
16
17export const metadata: Metadata = {
18 title: "Strapi Soft Delete",
19 description: "Implement Strapi Soft Delete",
20};
21
22export default function RootLayout({
23 children,
24}: {
25 children: React.ReactNode;
26}) {
27 return (
28 <html lang="en">
29 <body className={`${poppins.variable}`}>
30 <ToastContainer />
31 <div>
32 <div>
33 <div className="flex h-screen overflow-scroll">
34 {/* Side Bar */}
35 <div className="hidden sm:block">
36 <Sidebar />
37 </div>
38 <div></div>
39 {/* Main content */}
40 <div className="flex-1 p-4 w-full ml-0 sm:ml-[287px] overflow-hidden bg-white">
41 <div className="mt-4">{children}</div>
42 </div>
43 </div>
44 </div>
45 </div>
46 </body>
47 </html>
48 );
49}
As can be seen in the code above, we imported the SideBar
component into our layout.
Inside the app/page.tsx
file, add the following code:
1// path: app/page.tsx
2import Link from "next/link";
3import { CiBookmark } from "react-icons/ci";
4import DeleteArticle from "./components/Buttons/DeleteArticle";
5import { serverURL } from "./utils/urls";
6import SearchBar from "./components/SearchBar";
7
8export interface IArticle {
9 id: string;
10 attributes: {
11 name: string;
12 content: string;
13 deleted: boolean;
14 };
15}
16
17const getArticles = async () => {
18 try {
19 const response = await fetch(`${serverURL}/articles`, {
20 method: "GET",
21 cache: "no-cache",
22 });
23 return response.json();
24 } catch (error) {
25 console.error(error);
26 }
27};
28export default async function Home() {
29 const result = await getArticles();
30 const articles = result?.data;
31
32 return (
33 <div>
34 <SearchBar />
35 <div className="grid grid-cols-3 gap-y-10 my-10 mx-5 pb-20 relative">
36 {articles?.length > 0 ? (
37 articles.map((article: IArticle) => (
38 <div
39 key={article?.id}
40 className=" w-[270px] h-[250px] bg-primary shadow overflow-hidden px-1 pb-12 rounded-lg border-none hover:border hover:border-secondary hover:bg-tertiary"
41 >
42 <div className="flex items-center justify-between h-[30px] px-3 py-5">
43 <CiBookmark size={24} />
44 <div className="flex justify-end items-center">
45 <span className="text-[14px] font-bold">
46 {article?.attributes?.name.length <= 20
47 ? article?.attributes?.name
48 : article?.attributes?.name?.slice(0, 20) + "..."}
49 </span>
50 <DeleteArticle articleId={article?.id} />
51 </div>
52 </div>
53 <Link
54 href={`/articles/${article?.id}`}
55 className="h-[90%] rounded-lg px-10 bg-white flex flex-col justify-center space-y-3 text-[5px] border"
56 >
57 <span className="font-bold text-[7px]">
58 {article?.attributes?.name}
59 </span>
60 <span> {article?.attributes?.content}</span>
61 </Link>
62 </div>
63 ))
64 ) : (
65 <p className="text-center col-span-3 text-[14px] text-red-500 bg-tertiary p-5 w-full">
66 No article available at the moment
67 </p>
68 )}
69 </div>
70 </div>
71 );
72}
The code above imports the SearchBar
, DeleteArticle
button and the serverURL
utility. It makes a request to the server to fetch articles.
The homepage should look like that:
Create a folder named bin
and create a page.tsx
file inside it. Add the following code inside the new file:
1// path: app/bin/page.tsx
2import RecoverArticle from "../components/Buttons/RecoverArticle";
3import BackButton from "../components/Buttons/BackButton";
4import { serverURL } from "../utils/urls";
5import PermanentDeleteArticle from "../components/Buttons/PermanentDeleteArticle";
6import EmptyTrashButton from "../components/Buttons/EmptyTrashButton";
7
8export interface IArticle {
9 id: string;
10 attributes: {
11 name: string;
12 deleted: boolean;
13 };
14}
15
16const getBin = async () => {
17 try {
18 const response = await fetch(`${serverURL}/articles/bin`, {
19 method: "GET",
20 cache: "no-cache",
21 });
22
23 return response.json();
24 } catch (error) {
25 return null;
26 }
27};
28
29export default async function page() {
30 const result = await getBin();
31 const articles = result?.data;
32
33 return (
34 <div>
35 <BackButton />
36 <p className="text-lg font-bold my-7">Welcome to Bin</p>
37 <p className="p-5 bg-tertiary my-7 text-center font-thin">
38 Articles that have been in Bin more than 30 days will be automatically
39 deleted.
40 <EmptyTrashButton />
41 </p>
42 <div className="flex flex-col my-10 py-10 text-[14px]">
43 {articles?.length > 0 ? (
44 articles.map((article: IArticle) => (
45 <div
46 key={article.id}
47 className="hover:bg-primary border-b p-2 flex items-center justify-between "
48 >
49 <div className="flex items-center">
50 <span className="ml-3">{article?.attributes?.name}</span>
51 </div>
52 <div className="flex">
53 <PermanentDeleteArticle articleId={article?.id} />
54 <RecoverArticle articleId={article?.id} />
55 </div>
56 </div>
57 ))
58 ) : (
59 <p className="text-center text-[14px] text-red-500 bg-tertiary p-5">
60 No article in the bin at the moment!
61 </p>
62 )}
63 </div>
64 </div>
65 );
66}
The code above fetches a list of articles from a server bin using the getBin()
function, and then displays them. The page includes navigation elements (BackButton
), a heading welcoming to the bin, information about automatic deletion of articles older than 30 days, and buttons (PermanentDeleteArticle
and RecoverArticle
) for interacting with individual articles in the bin. It also comes with the button EmptyTrashButton
to allow a user empty their trash or bin. If there are no articles in the bin, a corresponding message is displayed.
Here is what it should look like:
By the end of this tutorial, you should have a working soft delete application that will enable you to recover an article, or delete it permanently:
In this article, we explored the necessity of soft delete functionality and demonstrated how to implement custom soft delete logic by creating a project with an integrated recycle bin. This was achieved by customizing Strapi's controllers with new actions specific to our soft delete logic. Additionally, we crafted custom routes to manage this functionality. To automate the process of permanently deleting soft-deleted articles after 30 days, we established a daily cron job scheduled to run at midnight.
Theodore is a Technical Writer and a full-stack software developer. He loves writing technical articles, building solutions, and sharing his expertise.