Welcome back to our Epic Next.js tutorial series!
Last time, we learned how to generate summaries using OpenAI and save them into our database.
Today, we'll take a look at how to update and delete our summaries while ensuring that only an authorized user can do this.
We must ensure that only the right people can change or delete information.
We'll tackle this challenge using Strapi's route middleware, ensuring only authorized users can interact with their summaries.
Let's briefly revisit the basics of CRUD—Create, Read, Update, and Delete—essential operations for any web application:
Create (POST): This method sends data to the server to create a new resource, like a summary.
Read (GET): GET requests to retrieve data from the server, such as loading summary details.
Update (PUT): Used for modifying an existing resource, like updating a user's bio.
Delete (DELETE): Removes resources, such as deleting a user's summary.
To better understand how to interact with our database, here's how Strapi maps CRUD operations to specific HTTP methods and routes:
Create Summary: POST /api/summaries
Find Summaries: GET /api/summaries
Find One Summary: GET /api/summaries/:id
Update Summary: PUT /api/summaries/:id
Delete Summary: DELETE /api/summaries/:id
Here's a basic breakdown of what happens in Strapi when a request is made:
Make a Request: A client (like a browser or mobile app) sends a request to the server.
Hit a Route: The request reaches Strapi and matches one of the predefined routes.
Call the Controller: The route triggers a controller function that handles the specifics of the request, such as retrieving data, updating a record, or deleting an item.
Managing Authorized Requests with JWT Tokens: Strapi uses JSON Web Tokens (JWT) to ensure that each request is legitimate. Here's how it works: Users who log in receive a JWT, which must be included as a 'Bearer token' in the header of subsequent requests. This token is validated on each request to ensure it's still valid and that the user is authenticated. However, authenticating a request doesn't automatically mean it's authorized. Just because a user is logged in and has a token doesn't mean they should be able to modify any data they want. We need to define the logic that will prevent users from modifying data that isn't theirs.
Note: Strapi does not handle this out of the box, so we need to write custom logic within our middleware.
Route middleware in Strapi acts as a security checkpoint for each request, where we can add additional checks, such as such as whether the user is authorized to update or delete content.
Intercept Requests: Middleware functions intercept incoming requests before they reach their final destination.
Check Permissions: It assesses whether the user's token grants them the right to perform the requested action.
Allow or Deny Access: If the user lacks necessary permissions, the middleware denies access and may return a response like "403 Forbidden".
Read more about Strapi middlewares here.
This system prevents unauthorized data modification and helps maintain a clear separation of duties within the application, simplifying management and enhancing security.
This approach enhances security and helps maintain a clean separation of concerns within the application, making the codebase more straightforward to manage and scale.
Now that we know we need to create our own middleware to handle the authorization check, let's implement our form logic first, then add the middleware.
In your project's frontend, navigate to the following file summary-card-form.tsx
.
You should see the following code that we added in the previous tutorial.
1// import { updateSummaryAction, deleteSummaryAction } from "@/data/actions/summary-actions";
2import { cn } from "@/lib/utils";
3
4import { Input } from "@/components/ui/input";
5import { Textarea } from "@/components/ui/textarea";
6
7import {
8 Card,
9 CardContent,
10 CardFooter,
11 CardHeader,
12 CardTitle,
13} from "@/components/ui/card";
14
15import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
16
17import { SubmitButton } from "@/components/custom/submit-button";
18import ReactMarkdown from "react-markdown";
19// import { DeleteButton } from "@/components/custom/delete-button";
20
21export function SummaryCardForm({
22 item,
23 className,
24}: {
25 readonly item: any;
26 readonly className?: string;
27}) {
28 // const deleteSummaryById = deleteSummaryAction.bind(null, item.documentId);
29
30 return (
31 <Card className={cn("mb-8 relative h-auto", className)}>
32 <CardHeader>
33 <CardTitle>Video Summary</CardTitle>
34 </CardHeader>
35 <CardContent>
36 <div>
37 <form>
38 <Input
39 id="title"
40 name="title"
41 placeholder="Update your title"
42 required
43 className="mb-4"
44 defaultValue={item.title}
45 />
46 <div className="flex-1 flex flex-col">
47 <Tabs
48 defaultValue="preview"
49 className="flex flex-col h-full gap-2"
50 >
51 <TabsList className="grid w-full grid-cols-2">
52 <TabsTrigger value="preview">Preview</TabsTrigger>
53 <TabsTrigger value="markdown">Edit Markdown</TabsTrigger>
54 </TabsList>
55 <TabsContent value="preview" className="flex-1">
56 <ReactMarkdown
57 className="
58 markdown-preview
59 relative w-full h-[600px]
60 overflow-auto scroll-smooth
61 p-4 px-3 py-2
62 text-sm
63 bg-white dark:bg-gray-800 bg-transparent
64 border border-gray-300 dark:border-gray-700
65 rounded-md
66 shadow-sm
67 mb-4
68 placeholder:text-muted-foreground
69 focus-visible:outline-none
70 focus-visible:bg-gray-50
71 focus-visible:ring-1
72 focus-visible:ring-ring
73 disabled:cursor-not-allowed
74 disabled:opacity-50
75 "
76 >
77 {item.summary}
78 </ReactMarkdown>
79 </TabsContent>
80 <TabsContent value="markdown" className="flex-1">
81 <Textarea
82 name="summary"
83 className="
84 markdown-preview
85 relative w-full h-[600px]
86 overflow-auto scroll-smooth
87 p-4 px-3 py-2
88 text-sm
89 bg-white dark:bg-gray-800 bg-transparent
90 border border-gray-300 dark:border-gray-700
91 rounded-md
92 shadow-sm
93 mb-4
94 placeholder:text-muted-foreground
95 focus-visible:outline-none
96 focus-visible:bg-gray-50
97 focus-visible:ring-1
98 focus-visible:ring-ring
99 disabled:cursor-not-allowed
100 disabled:opacity-50
101 "
102 defaultValue={item.summary}
103 />
104 </TabsContent>
105 </Tabs>
106 </div>
107 <input type="hidden" name="id" value={item.documentId} />
108 <SubmitButton
109 text="Update Summary"
110 loadingText="Updating Summary"
111 />
112 </form>
113 <form>
114 {/* <DeleteButton className="absolute right-4 top-4 bg-red-700 hover:bg-red-600" /> */}
115 </form>
116 </div>
117 </CardContent>
118 <CardFooter></CardFooter>
119 </Card>
120 );
121}
In the code above, we have yet to implement our updateSummaryAction
and deleteSummaryAction.
Let's start with adding our new server actions; let's navigate to our src/data/actions
folder, and, in the file name summary-actions.ts,
, add update with the following code.
1"use server";
2
3import { getAuthToken } from "@/data/services/get-token";
4import { mutateData } from "@/data/services/mutate-data";
5import { redirect } from "next/navigation";
6import { revalidatePath } from "next/cache";
7
8interface Payload {
9 data: {
10 title?: string;
11 videoId: string;
12 summary: string;
13 };
14}
15
16export async function createSummaryAction(payload: Payload) {
17 const authToken = await getAuthToken();
18 if (!authToken) throw new Error("No auth token found");
19
20 const data = await mutateData("POST", "/api/summaries", payload);
21
22 if (data.error) throw new Error(data.error.message);
23
24 redirect("/dashboard/summaries/" + data.data.documentId);
25}
26
27export async function updateSummaryAction(prevState: any, formData: FormData) {
28 const rawFormData = Object.fromEntries(formData);
29 const id = rawFormData.id as string;
30
31 const payload = {
32 data: {
33 title: rawFormData.title,
34 summary: rawFormData.summary,
35 },
36 };
37
38 const responseData = await mutateData("PUT", `/api/summaries/${id}`, payload);
39
40 if (!responseData) {
41 return {
42 ...prevState,
43 strapiErrors: null,
44 message: "Oops! Something went wrong. Please try again.",
45 };
46 }
47
48 if (responseData.error) {
49 return {
50 ...prevState,
51 strapiErrors: responseData.error,
52 message: "Failed to update summary.",
53 };
54 }
55
56 revalidatePath("/dashboard/summaries");
57
58 return {
59 ...prevState,
60 message: "Summary updated successfully",
61 data: responseData,
62 strapiErrors: null,
63 };
64}
65
66export async function deleteSummaryAction(id: string, prevState: any) {
67 const responseData = await mutateData("DELETE", `/api/summaries/${id}`);
68
69 if (!responseData) {
70 return {
71 ...prevState,
72 strapiErrors: null,
73 message: "Oops! Something went wrong. Please try again.",
74 };
75 }
76
77 redirect("/dashboard/summaries");
78}
Now that we have both server actions to handle update and delete let's navigate back to our summary-card-form.tsx
file.
Let's start by uncommenting our server actions import.
1import {
2 updateSummaryAction,
3 deleteSummaryAction,
4} from "@/data/actions/summary-actions";
Then, let's import the following to access the form state for error handling.
1import { useActionState } from "react";
Don't forget, since we are using useActionState
we need to add "use client";
at the top of the file.
Now, let's set the default state to the following:
1const INITIAL_STATE = {
2 strapiErrors: null,
3 data: null,
4 message: null,
5};
Let's connect our actions with our formState
by adding the following.
1const deleteSummaryById = deleteSummaryAction.bind(null, item.id);
2
3const [deleteState, deleteAction] = useActionState(
4 deleteSummaryById,
5 INITIAL_STATE
6);
7
8const [updateState, updateAction] = useActionState(
9 updateSummaryAction,
10 INITIAL_STATE
11);
Based on what we have already covered, this should all start looking familiar. We are using useActionState
to get our return from our server actions to display our errors on our frontend.
Now that we have our deleteAction
and updateAction,
we must add these to the respectful forms via the action attribute.
1<form action={updateAction}>
2 <Input
3 id="title"
4 name="title"
5 placeholder="Update your title"
6 required
7 className="mb-4"
8 defaultValue={item.title}
9 />
10 <div className="flex-1 flex flex-col">
11 <Tabs defaultValue="preview" className="flex flex-col h-full gap-2">
12 <TabsList className="grid w-full grid-cols-2">
13 <TabsTrigger value="preview">Preview</TabsTrigger>
14 <TabsTrigger value="markdown">Edit Markdown</TabsTrigger>
15 </TabsList>
16 <TabsContent value="preview" className="flex-1">
17 <ReactMarkdown
18 className="
19 markdown-preview
20 relative w-full h-[600px]
21 overflow-auto scroll-smooth
22 p-4 px-3 py-2
23 text-sm
24 bg-white dark:bg-gray-800 bg-transparent
25 border border-gray-300 dark:border-gray-700
26 rounded-md
27 shadow-sm
28 mb-4
29 placeholder:text-muted-foreground
30 focus-visible:outline-none
31 focus-visible:bg-gray-50
32 focus-visible:ring-1
33 focus-visible:ring-ring
34 disabled:cursor-not-allowed
35 disabled:opacity-50
36 "
37 >
38 {item.summary}
39 </ReactMarkdown>
40 </TabsContent>
41 <TabsContent value="markdown" className="flex-1">
42 <Textarea
43 name="summary"
44 className="
45 markdown-preview
46 relative w-full h-[600px]
47 overflow-auto scroll-smooth
48 p-4 px-3 py-2
49 text-sm
50 bg-white dark:bg-gray-800 bg-transparent
51 border border-gray-300 dark:border-gray-700
52 rounded-md
53 shadow-sm
54 mb-4
55 placeholder:text-muted-foreground
56 focus-visible:outline-none
57 focus-visible:bg-gray-50
58 focus-visible:ring-1
59 focus-visible:ring-ring
60 disabled:cursor-not-allowed
61 disabled:opacity-50
62 "
63 defaultValue={item.summary}
64 />
65 </TabsContent>
66 </Tabs>
67 </div>
68 <input type="hidden" name="id" value={item.documentId} />
69 <SubmitButton text="Update Summary" loadingText="Updating Summary" />
70</form>
1<form action={deleteAction}>
2 <DeleteButton className="absolute right-4 top-4 bg-red-700 hover:bg-red-600" />
3</form>
Finally, let's import our StrapiError
component with the following.
1import { StrapiErrors } from "@/components/custom/strapi-errors";
And use it in our card footer.
1<CardFooter>
2 <StrapiErrors
3 error={deleteState?.strapiErrors || updateState?.strapiErrors}
4 />
5</CardFooter>
The completed code should look like the following.
1"use client";
2import { useActionState } from "react";
3
4import {
5 updateSummaryAction,
6 deleteSummaryAction,
7} from "@/data/actions/summary-actions";
8import { cn } from "@/lib/utils";
9
10import { Input } from "@/components/ui/input";
11import { Textarea } from "@/components/ui/textarea";
12
13import { StrapiErrors } from "@/components/custom/strapi-errors";
14
15import {
16 Card,
17 CardContent,
18 CardFooter,
19 CardHeader,
20 CardTitle,
21} from "@/components/ui/card";
22
23import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
24
25import { SubmitButton } from "@/components/custom/submit-button";
26import ReactMarkdown from "react-markdown";
27import { DeleteButton } from "@/components/custom/delete-button";
28
29const INITIAL_STATE = {
30 strapiErrors: null,
31 data: null,
32 message: null,
33};
34
35export function SummaryCardForm({
36 item,
37 className,
38}: {
39 readonly item: any;
40 readonly className?: string;
41}) {
42 const deleteSummaryById = deleteSummaryAction.bind(null, item.documentId);
43
44 const [deleteState, deleteAction] = useActionState(
45 deleteSummaryById,
46 INITIAL_STATE
47 );
48
49 const [updateState, updateAction] = useActionState(
50 updateSummaryAction,
51 INITIAL_STATE
52 );
53 return (
54 <Card className={cn("mb-8 relative h-auto", className)}>
55 <CardHeader>
56 <CardTitle>Video Summary</CardTitle>
57 </CardHeader>
58 <CardContent>
59 <div>
60 <form action={updateAction}>
61 <Input
62 id="title"
63 name="title"
64 placeholder="Update your title"
65 required
66 className="mb-4"
67 defaultValue={item.title}
68 />
69 <div className="flex-1 flex flex-col">
70 <Tabs
71 defaultValue="preview"
72 className="flex flex-col h-full gap-2"
73 >
74 <TabsList className="grid w-full grid-cols-2">
75 <TabsTrigger value="preview">Preview</TabsTrigger>
76 <TabsTrigger value="markdown">Edit Markdown</TabsTrigger>
77 </TabsList>
78 <TabsContent value="preview" className="flex-1">
79 <ReactMarkdown
80 className="
81 markdown-preview
82 relative w-full h-[600px]
83 overflow-auto scroll-smooth
84 p-4 px-3 py-2
85 text-sm
86 bg-white dark:bg-gray-800 bg-transparent
87 border border-gray-300 dark:border-gray-700
88 rounded-md
89 shadow-sm
90 mb-4
91 placeholder:text-muted-foreground
92 focus-visible:outline-none
93 focus-visible:bg-gray-50
94 focus-visible:ring-1
95 focus-visible:ring-ring
96 disabled:cursor-not-allowed
97 disabled:opacity-50
98 "
99 >
100 {item.summary}
101 </ReactMarkdown>
102 </TabsContent>
103 <TabsContent value="markdown" className="flex-1">
104 <Textarea
105 name="summary"
106 className="
107 markdown-preview
108 relative w-full h-[600px]
109 overflow-auto scroll-smooth
110 p-4 px-3 py-2
111 text-sm
112 bg-white dark:bg-gray-800 bg-transparent
113 border border-gray-300 dark:border-gray-700
114 rounded-md
115 shadow-sm
116 mb-4
117 placeholder:text-muted-foreground
118 focus-visible:outline-none
119 focus-visible:bg-gray-50
120 focus-visible:ring-1
121 focus-visible:ring-ring
122 disabled:cursor-not-allowed
123 disabled:opacity-50
124 "
125 defaultValue={item.summary}
126 />
127 </TabsContent>
128 </Tabs>
129 </div>
130 <input type="hidden" name="id" value={item.documentId} />
131 <SubmitButton
132 text="Update Summary"
133 loadingText="Updating Summary"
134 />
135 </form>
136 <form action={deleteAction}>
137 <DeleteButton className="absolute right-4 top-4 bg-red-700 hover:bg-red-600" />
138 </form>
139 </div>
140 </CardContent>
141 <CardFooter>
142 <StrapiErrors
143 error={deleteState?.strapiErrors || updateState?.strapiErrors}
144 />
145 </CardFooter>
146 </Card>
147 );
148}
Now, let's test out our frontend. I disabled delete
and update
under user permission for authenticated users.
So, if we try to delete
or update
our summary, we should see the forbidden
message in our card footer.
Excellent, we can tell our errors are working. Let's set the following permissions again and see if our update and delete functions work.
You should now see that you can update and delete your content.
But we have one small issue. When we make a GET
request to the /api/summaries
endpoint, we get all the summaries. And we want to make sure that we only get the summaries for the user that is logged in.
Here in Strapi Admin, you can see that I have two summaries from two different users. Still, in the frontend, you can see that I have access to all the summaries.
I should only see the summaries from the user that is logged in. But instead, I see all the summaries.
Let's fix this by creating a new middleware in the next section.
Let's create a new middleware to only show summaries from the user who is logged in by applying a filter inside of our middleware.
Let's start in our Strapi project folder, so make sure you are on the backend of your project and run the following command.
yarn strapi generate
Select the middleware
option.
➜ backend git:(main) ✗ yarn strapi generate
yarn run v1.22.22
$ strapi generate
? Strapi Generators
api - Generate a basic API
controller - Generate a controller for an API
content-type - Generate a content type for an API
policy - Generate a policy for an API
❯ middleware - Generate a middleware for an API
migration - Generate a migration
service - Generate a service for an API
I am going to call my middleware is-owner
and we will add it to the root of the project.
? Middleware name is-owner
? Where do you want to add this middleware? (Use arrow keys)
❯ Add middleware to root of project
Add middleware to an existing API
Add middleware to an existing plugin
Select the Add middleware to root of project
option and press Enter
.
✔ ++ /middlewares/is-owner.js
✨ Done in 327.55s.
Our middleware is located in the root of our project, in a folder called src/middlewares
inside the is-owner
file.
It will have this basic template code.
1/**
2 * `is-owner` middleware
3 */
4
5import type { Core } from "@strapi/strapi";
6
7export default (config, { strapi }: { strapi: Core.Strapi }) => {
8 // Add your own logic here.
9 return async (ctx, next) => {
10 strapi.log.info("In is-owner middleware.");
11
12 await next();
13 };
14};
Let's update the code with the following.
1/**
2 * `is-owner` middleware
3 */
4
5import type { Core } from "@strapi/strapi";
6
7export default (config, { strapi }: { strapi: Core.Strapi }) => {
8 // Add your own logic here.
9 return async (ctx, next) => {
10 strapi.log.info("In is-owner middleware.");
11
12 const entryId = ctx.params.id;
13 const user = ctx.state.user;
14 const userId = user?.documentId;
15
16 if (!userId) return ctx.unauthorized(`You can't access this entry`);
17
18 const apiName = ctx.state.route.info.apiName;
19
20 function generateUID(apiName) {
21 const apiUid = `api::${apiName}.${apiName}`;
22 return apiUid;
23 }
24
25 const appUid = generateUID(apiName);
26
27 if (entryId) {
28 const entry = await strapi.documents(appUid as any).findOne({
29 documentId: entryId,
30 populate: "*",
31 });
32
33 if (entry && entry.authorId !== userId)
34 return ctx.unauthorized(`You can't access this entry`);
35 }
36
37 if (!entryId) {
38 ctx.query = {
39 ...ctx.query,
40 filters: { ...ctx.query.filters, authorId: userId },
41 };
42 }
43
44 await next();
45 };
46};
The code above will check for two cases. If entryId
exists, that means we are calling the findOne
route. In this case, we search for the entry and check if the userId
is the same as the logged-in user. If that is the case, go ahead and return the entry.
In the second case, if there is no entryId
, we assume that we are making a GET
request, in which case filter the content by userId
.
Before we can test the middleware, we need to add the appropriate routes.
In Strapi, navigate to the following folder and file src/api/summary/routes/summary.js,
and you should see this code.
1/**
2 * summary router
3 */
4
5import { factories } from "@strapi/strapi";
6
7export default factories.createCoreRouter("api::summary.summary", {
8 config: {
9 create: {
10 middlewares: ["api::summary.on-summary-create"],
11 },
12 },
13});
We have already added middleware to our create
route. Let's do the same for find
, findOne
, update
, and delete
.
Completed code with the changes should look like the following.
1/**
2 * summary router
3 */
4
5import { factories } from "@strapi/strapi";
6
7export default factories.createCoreRouter("api::summary.summary", {
8 config: {
9 create: {
10 middlewares: ["api::summary.on-summary-create"],
11 },
12 find: {
13 middlewares: ["global::is-owner"],
14 },
15 findOne: {
16 middlewares: ["global::is-owner"],
17 },
18 update: {
19 middlewares: ["global::is-owner"],
20 },
21 delete: {
22 middlewares: ["global::is-owner"],
23 },
24 },
25});
Whenever any of these routes get called, it will trigger our middleware that will either reject the request or approve it based on our logic.
Let's restart our Strapi backend and see if we are only able to see the post for the appropriate user.
In this tutorial, we focused on making our app more secure and user-friendly by setting up CRUD operations that are specific to each user. This way, each person can only update or delete their own summaries, adding a strong layer of protection and control. With custom middleware handling these checks, we made sure that users see only their own content and that any attempts to view or change someone else’s data are blocked.
Here’s a quick summary of everything we covered:
This setup combines Strapi’s middleware with Next.js to create a simple and secure app that works well even as more users join. Now, each user has a clear view of their own data, and we’re keeping everything safe by preventing unauthorized access.
In the next part, we’ll keep building out new features to make this app even better. Thanks for following along, and happy coding! See you in the next tutorial!
This project has been updated to use Next.js 15 and Strapi 5.
If you have any questions, feel free to stop by at our Discord Community for our daily "open office hours" from 12:30 PM CST to 1:30 PM CST.
If you have a suggestion or find a mistake in the post, please open an issue on the GitHub repository.
You can also find the blog post content in the Strapi Blog.
Feel free to make PRs to fix any issues you find in the project, or let me know if you have any questions.
Happy coding!