Welcome back to our Epic Next.js tutorial series!
In our previous tutorial, we learned how to generate video summaries using OpenAI and save them to our database. Now we're ready to take the next step.
Previous Tutorials:
- Part 1: Learn Next.js by building a website
- Part 2: Building Out The Hero Section of the homepage
- Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
- Part 4: How to handle login and Authentification in Next.js
- Part 5: Building out the Dashboard page and upload file using NextJS server actions
- Part 6: Get Video Transcript with AI SDK
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
What We'll Build Today
In this tutorial, we'll implement secure update and delete functionality for our video summaries. Users will be able to edit their summaries and remove ones they no longer need - but only their own content.
Key Security Challenge: We need to ensure that users can only modify or delete summaries they own. A user should never be able to access or change another user's content.
We'll solve this using a combination of modern Next.js server actions and custom Strapi middleware for bulletproof security.
Understanding CRUD Operations
Before diving into implementation, let's review the four essential database operations that power our application:
The Four CRUD Operations:
- Create (POST): Adds new content to our database - like saving a new video summary
- Read (GET): Retrieves existing data - like loading a summary or listing all summaries
- Update (PUT): Modifies existing content - like editing a summary's title or content
- Delete (DELETE): Permanently removes content - like deleting an unwanted summary
How Strapi Handles CRUD
Strapi automatically creates RESTful API endpoints for our summary content type:
- Create Summary:
POST /api/summaries
- Find All Summaries:
GET /api/summaries
- Find One Summary:
GET /api/summaries/:id
- Update Summary:
PUT /api/summaries/:id
- Delete Summary:
DELETE /api/summaries/:id
These endpoints form the backbone of our application's data operations.
The Security Challenge
While Strapi provides basic authentication through JWT tokens, there's an important distinction we need to understand:
Authentication vs Authorization:
- Authentication: "Who are you?" - Handled by JWT tokens when users log in
- Authorization: "What are you allowed to do?" - This is where we need custom logic
Here's the problem: By default, an authenticated user can access any summary in the system, not just their own. If User A creates a summary, User B (who is also logged in) could potentially view, edit, or delete it.
The Current Flow: 1. User logs in and receives a JWT token 2. User makes a request with their token 3. Strapi validates the token (authentication ✅) 4. Strapi allows access to all summaries (authorization ❌)
What We Need: Custom middleware that checks ownership before allowing operations on summaries.
Understanding Route Middleware
Think of route middleware as a security guard at a building entrance. Every request must pass through this checkpoint before accessing your data.
How Middleware Works
The Middleware Process:
- Intercept: Every incoming request is captured before it reaches the database
- Authenticate: Verify the user has a valid login token
- Authorize: Check if the user owns the content they're trying to access
- Allow or Deny: Either let the request proceed or block it with an error
Why This Matters: Without proper middleware, any logged-in user could modify any summary in your system. Middleware ensures users can only access their own content.
Learn More: Check out the official Strapi middleware documentation for additional details.
Implementation Strategy
We'll tackle this in two phases: 1. Frontend: Build the update/delete forms using Next.js server actions 2. Backend: Create Strapi middleware to enforce ownership rules
Let's start with the frontend implementation.
Building the Frontend: Summary Update Form
Let's start by implementing the user interface for updating and deleting summaries. Our form will handle both operations securely using Next.js server actions.
In your project's frontend, locate the file summary-update-form.tsx
. Currently, it contains a basic form structure from our previous tutorial:
1"use client";
2import { EditorWrapper } from "@/components/custom/editor/editor-wrapper";
3import type { TSummary } from "@/types";
4
5import { Input } from "@/components/ui/input";
6import { SubmitButton } from "@/components/custom/submit-button";
7import { DeleteButton } from "@/components/custom/delete-button";
8
9interface ISummaryUpdateFormProps {
10 summary: TSummary;
11}
12
13const styles = {
14 container: "flex flex-col px-2 py-0.5 relative",
15 titleInput: "mb-3",
16 editor: "h-[calc(100vh-215px)] overflow-y-auto",
17 buttonContainer: "mt-3",
18 updateButton: "inline-block",
19 deleteFormContainer: "absolute bottom-0 right-2",
20 deleteButton: "bg-pink-500 hover:bg-pink-600",
21};
22
23export function SummaryUpdateForm({ summary }: ISummaryUpdateFormProps) {
24 return (
25 <div className={styles.container}>
26 <form>
27 <Input
28 id="title"
29 name="title"
30 type="text"
31 placeholder={"Title"}
32 defaultValue={summary.title || ""}
33 className={styles.titleInput}
34 />
35
36 <input type="hidden" name="content" defaultValue={summary.content} />
37
38 <div>
39 <EditorWrapper
40 markdown={summary.content}
41 onChange={(value) => {
42 const hiddenInput = document.querySelector('input[name="content"]') as HTMLInputElement;
43 if (hiddenInput) hiddenInput.value = value;
44 }}
45 className={styles.editor}
46 />
47 </div>
48
49 <div className={styles.buttonContainer}>
50 <div className={styles.updateButton}>
51 <SubmitButton
52 text="Update Summary"
53 loadingText="Updating Summary"
54 />
55 </div>
56 </div>
57 </form>
58
59 <div className={styles.deleteFormContainer}>
60 <form onSubmit={() => console.log("DELETE FORM SUBMITTED")}>
61 <DeleteButton className={styles.deleteButton} />
62 </form>
63 </div>
64 </div>
65 );
66}
As you can see, the form structure is there, but it's missing the actual functionality. The forms don't do anything yet because we haven't implemented the server actions.
Let's build this functionality step by step, following the same architectural patterns we used for profile management in earlier tutorials.
Step 1: Create Validation Schemas
First, we need to define what valid input looks like for our update and delete operations. This prevents invalid data from reaching our server and provides clear error messages to users.
Create a new file at src/data/validation/summary.ts
:
1import { z } from "zod";
2
3export const SummaryUpdateFormSchema = z.object({
4 title: z
5 .string()
6 .min(1, "Title is required")
7 .max(200, "Title must be less than 200 characters"),
8 content: z
9 .string()
10 .min(10, "Content must be at least 10 characters")
11 .max(50000, "Content must be less than 50,000 characters"),
12 documentId: z.string().min(1, "Document ID is required"),
13});
14
15export type SummaryUpdateFormValues = z.infer<typeof SummaryUpdateFormSchema>;
16
17export type SummaryUpdateFormState = {
18 success?: boolean;
19 message?: string;
20 data?: {
21 title?: string;
22 content?: string;
23 documentId?: string;
24 };
25 strapiErrors?: {
26 status: number;
27 name: string;
28 message: string;
29 details?: Record<string, string[]>;
30 } | null;
31 zodErrors?: {
32 title?: string[];
33 content?: string[];
34 documentId?: string[];
35 } | null;
36};
37
38export const SummaryDeleteFormSchema = z.object({
39 documentId: z.string().min(1, "Document ID is required"),
40});
41
42export type SummaryDeleteFormValues = z.infer<typeof SummaryDeleteFormSchema>;
43
44export type SummaryDeleteFormState = {
45 success?: boolean;
46 message?: string;
47 data?: {
48 documentId?: string;
49 };
50 strapiErrors?: {
51 status: number;
52 name: string;
53 message: string;
54 details?: Record<string, string[]>;
55 } | null;
56 zodErrors?: {
57 documentId?: string[];
58 } | null;
59};
Step 2: Create Update and Delete Services
Now we need to create the service functions that will communicate with our Strapi API. These services handle the actual HTTP requests to update and delete summaries.
In the src/data/services/summary/
directory, create two new files:
update-summary.ts:
1import qs from "qs";
2import { getStrapiURL } from "@/lib/utils";
3import type { TStrapiResponse, TSummary } from "@/types";
4import { api } from "@/data/data-api";
5import { actions } from "@/data/actions";
6
7const baseUrl = getStrapiURL();
8
9export async function updateSummaryService(
10 documentId: string,
11 summaryData: Partial<TSummary>
12): Promise<TStrapiResponse<TSummary>> {
13 const authToken = await actions.auth.getAuthTokenAction();
14 if (!authToken) throw new Error("You are not authorized");
15
16 const query = qs.stringify({
17 populate: "*",
18 });
19
20 const url = new URL(`/api/summaries/${documentId}`, baseUrl);
21 url.search = query;
22
23 // Strapi expects data to be wrapped in a 'data' object
24 const payload = { data: summaryData };
25 const result = await api.put<TSummary, typeof payload>(url.href, payload, { authToken });
26
27 return result;
28}
delete-summary.ts:
1import { getStrapiURL } from "@/lib/utils";
2import type { TStrapiResponse } from "@/types";
3import { api } from "@/data/data-api";
4import { actions } from "@/data/actions";
5
6const baseUrl = getStrapiURL();
7
8export async function deleteSummaryService(documentId: string): Promise<TStrapiResponse<null>> {
9 const authToken = await actions.auth.getAuthTokenAction();
10 if (!authToken) throw new Error("You are not authorized");
11
12 const url = new URL(`/api/summaries/${documentId}`, baseUrl);
13
14 const result = await api.delete<null>(url.href, { authToken });
15
16 return result;
17}
Step 3: Update Services Index Files
Now we need to make our new services available throughout the application by updating the index files.
Update src/data/services/summary/index.ts
to export the new services:
1import { generateTranscript } from "./generate-transcript";
2import { generateSummary } from "./generate-summary";
3import { saveSummaryService } from "./save-summary";
4import { updateSummaryService } from "./update-summary";
5import { deleteSummaryService } from "./delete-summary";
6
7export {
8 generateTranscript,
9 generateSummary,
10 saveSummaryService,
11 updateSummaryService,
12 deleteSummaryService
13};
And update src/data/services/index.ts
:
1// Add the new services to the summarize object
2summarize: {
3 generateTranscript,
4 generateSummary,
5 saveSummaryService,
6 updateSummaryService,
7 deleteSummaryService,
8},
Step 4: Create Server Actions
Server actions are the bridge between our forms and our services. They handle form data, validate it, and coordinate with our backend services.
Create a new file at src/data/actions/summary.ts
:
1"use server";
2import { z } from "zod";
3import { redirect } from "next/navigation";
4import { revalidatePath } from "next/cache";
5
6import { services } from "@/data/services";
7
8import {
9 SummaryUpdateFormSchema,
10 SummaryDeleteFormSchema,
11 type SummaryUpdateFormState,
12 type SummaryDeleteFormState,
13} from "@/data/validation/summary";
14
15export async function updateSummaryAction(
16 prevState: SummaryUpdateFormState,
17 formData: FormData
18): Promise<SummaryUpdateFormState> {
19 const fields = Object.fromEntries(formData);
20
21 const validatedFields = SummaryUpdateFormSchema.safeParse(fields);
22
23 if (!validatedFields.success) {
24 const flattenedErrors = z.flattenError(validatedFields.error);
25 return {
26 success: false,
27 message: "Validation failed",
28 strapiErrors: null,
29 zodErrors: flattenedErrors.fieldErrors,
30 data: {
31 ...prevState.data,
32 ...fields,
33 },
34 };
35 }
36
37 const { documentId, ...updateData } = validatedFields.data;
38
39 try {
40 const responseData = await services.summarize.updateSummaryService(
41 documentId,
42 updateData
43 );
44
45 if (responseData.error) {
46 return {
47 success: false,
48 message: "Failed to update summary.",
49 strapiErrors: responseData.error,
50 zodErrors: null,
51 data: {
52 ...prevState.data,
53 ...fields,
54 },
55 };
56 }
57
58 // Revalidate the current page and summaries list to show updated data
59 revalidatePath(`/dashboard/summaries/${documentId}`);
60 revalidatePath("/dashboard/summaries");
61
62 return {
63 success: true,
64 message: "Summary updated successfully!",
65 strapiErrors: null,
66 zodErrors: null,
67 data: {
68 ...prevState.data,
69 ...fields,
70 },
71 };
72 } catch (error) {
73 return {
74 success: false,
75 message: "Failed to update summary. Please try again.",
76 strapiErrors: null,
77 zodErrors: null,
78 data: {
79 ...prevState.data,
80 ...fields,
81 },
82 };
83 }
84}
85
86export async function deleteSummaryAction(
87 prevState: SummaryDeleteFormState,
88 formData: FormData
89): Promise<SummaryDeleteFormState> {
90 const fields = Object.fromEntries(formData);
91
92 const validatedFields = SummaryDeleteFormSchema.safeParse(fields);
93
94 if (!validatedFields.success) {
95 const flattenedErrors = z.flattenError(validatedFields.error);
96 return {
97 success: false,
98 message: "Validation failed",
99 strapiErrors: null,
100 zodErrors: flattenedErrors.fieldErrors,
101 data: {
102 ...prevState.data,
103 ...fields,
104 },
105 };
106 }
107
108 try {
109 const responseData = await services.summarize.deleteSummaryService(
110 validatedFields.data.documentId
111 );
112
113 if (responseData.error) {
114 return {
115 success: false,
116 message: "Failed to delete summary.",
117 strapiErrors: responseData.error,
118 zodErrors: null,
119 data: {
120 ...prevState.data,
121 ...fields,
122 },
123 };
124 }
125
126 // If we get here, deletion was successful
127 revalidatePath("/dashboard/summaries");
128 } catch (error) {
129 return {
130 success: false,
131 message: "Failed to delete summary. Please try again.",
132 strapiErrors: null,
133 zodErrors: null,
134 data: {
135 ...prevState.data,
136 ...fields,
137 },
138 };
139 }
140
141 // Redirect after successful deletion (outside try/catch)
142 redirect("/dashboard/summaries");
143}
Step 5: Export Actions
Make the new actions available by updating the actions index file.
Update src/data/actions/index.ts
to include the new summary actions:
1import { updateSummaryAction, deleteSummaryAction } from "./summary";
2
3export const actions = {
4 // ... existing actions
5 summary: {
6 updateSummaryAction,
7 deleteSummaryAction,
8 },
9};
Step 6: Update the Summary Update Form
Now comes the exciting part - connecting our form to the server actions we just created! We'll update the summary-update-form.tsx
file to handle both update and delete operations with proper error handling and user feedback.
First, let's update the imports at the top of the file:
1"use client";
2import React from "react";
3import { useActionState } from "react";
4
5import { EditorWrapper } from "@/components/custom/editor/editor-wrapper";
6import type { TSummary } from "@/types";
7import { actions } from "@/data/actions";
8import type { SummaryUpdateFormState, SummaryDeleteFormState } from "@/data/validation/summary";
9
10import { Input } from "@/components/ui/input";
11import { SubmitButton } from "@/components/custom/submit-button";
12import { DeleteButton } from "@/components/custom/delete-button";
13import { ZodErrors } from "@/components/custom/zod-errors";
14import { StrapiErrors } from "@/components/custom/strapi-errors";
Next, let's set up the initial states for our forms:
1const INITIAL_UPDATE_STATE: SummaryUpdateFormState = {
2 success: false,
3 message: undefined,
4 strapiErrors: null,
5 zodErrors: null,
6};
7
8const INITIAL_DELETE_STATE: SummaryDeleteFormState = {
9 success: false,
10 message: undefined,
11 strapiErrors: null,
12 zodErrors: null,
13};
Now let's update the component implementation:
1export function SummaryUpdateForm({ summary }: ISummaryUpdateFormProps) {
2 const [updateFormState, updateFormAction] = useActionState(
3 actions.summary.updateSummaryAction,
4 INITIAL_UPDATE_STATE
5 );
6
7 const [deleteFormState, deleteFormAction] = useActionState(
8 actions.summary.deleteSummaryAction,
9 INITIAL_DELETE_STATE
10 );
11
12 return (
13 <div className={styles.container}>
14 <form action={updateFormAction}>
15 <input type="hidden" name="documentId" value={summary.documentId} />
16
17 <div className={styles.fieldGroup}>
18 <Input
19 id="title"
20 name="title"
21 type="text"
22 placeholder={"Title"}
23 defaultValue={updateFormState?.data?.title || summary.title || ""}
24 className={styles.titleInput}
25 />
26 <ZodErrors error={updateFormState?.zodErrors?.title} />
27 </div>
28
29 <input
30 type="hidden"
31 name="content"
32 defaultValue={updateFormState?.data?.content || summary.content}
33 />
34
35 <div className={styles.fieldGroup}>
36 <EditorWrapper
37 markdown={updateFormState?.data?.content || summary.content}
38 onChange={(value) => {
39 const hiddenInput = document.querySelector('input[name="content"]') as HTMLInputElement;
40 if (hiddenInput) hiddenInput.value = value;
41 }}
42 className={styles.editor}
43 />
44 <ZodErrors error={updateFormState?.zodErrors?.content} />
45 </div>
46
47 <div className={styles.buttonContainer}>
48 <div className={styles.updateButton}>
49 <SubmitButton
50 text="Update Summary"
51 loadingText="Updating Summary"
52 />
53 </div>
54 </div>
55
56 <StrapiErrors error={updateFormState?.strapiErrors} />
57 {updateFormState?.success && (
58 <div className="text-green-600 mt-2">{updateFormState.message}</div>
59 )}
60 {updateFormState?.message && !updateFormState?.success && (
61 <div className="text-red-600 mt-2">{updateFormState.message}</div>
62 )}
63 </form>
64
65 <div className={styles.deleteFormContainer}>
66 <form action={deleteFormAction}>
67 <input type="hidden" name="documentId" value={summary.documentId} />
68 <DeleteButton className={styles.deleteButton} />
69 </form>
70
71 <StrapiErrors error={deleteFormState?.strapiErrors} />
72 {deleteFormState?.message && !deleteFormState?.success && (
73 <div className="text-red-600 mt-1 text-sm">{deleteFormState.message}</div>
74 )}
75 </div>
76 </div>
77 );
78}
The key changes we made:
- Added proper form state management using
useActionState
for both update and delete operations - Implemented validation error display using
ZodErrors
component for field-specific errors - Added success and error messaging with proper styling
- Used hidden inputs to pass the
documentId
to both forms - Preserved form data on validation errors by using form state data as fallback values
- Separated concerns between the update and delete forms with their own error handling
Important Note About Redirects
Don't Panic About "NEXT_REDIRECT" Errors!
When testing the delete functionality, you might see a scary-looking "NEXT_REDIRECT" error in your browser console. This is completely normal and expected - it's just how Next.js handles redirects internally.
What's Happening:
- User clicks delete button
- Summary gets deleted successfully
- Next.js throws a special "NEXT_REDIRECT" error to trigger the redirect
- User gets redirected to the summaries list
- Everything works perfectly!
The error message looks alarming but it's actually a sign that everything is working correctly.
Here's what the complete summary-update-form.tsx
file should look like after all the changes:
1"use client";
2import React from "react";
3import { useActionState } from "react";
4
5import { EditorWrapper } from "@/components/custom/editor/editor-wrapper";
6import type { TSummary } from "@/types";
7import { actions } from "@/data/actions";
8import type { SummaryUpdateFormState, SummaryDeleteFormState } from "@/data/validation/summary";
9
10import { Input } from "@/components/ui/input";
11import { SubmitButton } from "@/components/custom/submit-button";
12import { DeleteButton } from "@/components/custom/delete-button";
13import { ZodErrors } from "@/components/custom/zod-errors";
14import { StrapiErrors } from "@/components/custom/strapi-errors";
15
16interface ISummaryUpdateFormProps {
17 summary: TSummary;
18}
19
20const INITIAL_UPDATE_STATE: SummaryUpdateFormState = {
21 success: false,
22 message: undefined,
23 strapiErrors: null,
24 zodErrors: null,
25};
26
27const INITIAL_DELETE_STATE: SummaryDeleteFormState = {
28 success: false,
29 message: undefined,
30 strapiErrors: null,
31 zodErrors: null,
32};
33
34const styles = {
35 container: "flex flex-col px-2 py-0.5 relative",
36 titleInput: "mb-3",
37 editor: "h-[calc(100vh-215px)] overflow-y-auto",
38 buttonContainer: "mt-3",
39 updateButton: "inline-block",
40 deleteFormContainer: "absolute bottom-0 right-2",
41 deleteButton: "bg-pink-500 hover:bg-pink-600",
42 fieldGroup: "space-y-1",
43};
44
45export function SummaryUpdateForm({ summary }: ISummaryUpdateFormProps) {
46 const [updateFormState, updateFormAction] = useActionState(
47 actions.summary.updateSummaryAction,
48 INITIAL_UPDATE_STATE
49 );
50
51 const [deleteFormState, deleteFormAction] = useActionState(
52 actions.summary.deleteSummaryAction,
53 INITIAL_DELETE_STATE
54 );
55
56 return (
57 <div className={styles.container}>
58 <form action={updateFormAction}>
59 <input type="hidden" name="documentId" value={summary.documentId} />
60
61 <div className={styles.fieldGroup}>
62 <Input
63 id="title"
64 name="title"
65 type="text"
66 placeholder={"Title"}
67 defaultValue={updateFormState?.data?.title || summary.title || ""}
68 className={styles.titleInput}
69 />
70 <ZodErrors error={updateFormState?.zodErrors?.title} />
71 </div>
72
73 <input
74 type="hidden"
75 name="content"
76 defaultValue={updateFormState?.data?.content || summary.content}
77 />
78
79 <div className={styles.fieldGroup}>
80 <EditorWrapper
81 markdown={updateFormState?.data?.content || summary.content}
82 onChange={(value) => {
83 const hiddenInput = document.querySelector('input[name="content"]') as HTMLInputElement;
84 if (hiddenInput) hiddenInput.value = value;
85 }}
86 className={styles.editor}
87 />
88 <ZodErrors error={updateFormState?.zodErrors?.content} />
89 </div>
90
91 <div className={styles.buttonContainer}>
92 <div className={styles.updateButton}>
93 <SubmitButton
94 text="Update Summary"
95 loadingText="Updating Summary"
96 />
97 </div>
98 </div>
99
100 <StrapiErrors error={updateFormState?.strapiErrors} />
101 {updateFormState?.success && (
102 <div className="text-green-600 mt-2">{updateFormState.message}</div>
103 )}
104 {updateFormState?.message && !updateFormState?.success && (
105 <div className="text-red-600 mt-2">{updateFormState.message}</div>
106 )}
107 </form>
108
109 <div className={styles.deleteFormContainer}>
110 <form action={deleteFormAction}>
111 <input type="hidden" name="documentId" value={summary.documentId} />
112 <DeleteButton className={styles.deleteButton} />
113 </form>
114
115 <StrapiErrors error={deleteFormState?.strapiErrors} />
116 {deleteFormState?.message && !deleteFormState?.success && (
117 <div className="text-red-600 mt-1 text-sm">{deleteFormState.message}</div>
118 )}
119 </div>
120 </div>
121 );
122}
Testing Our Update and Delete Features
Before we can test our new functionality, we need to ensure the proper permissions are set in Strapi. Let's make sure users can actually update and delete their summaries.
In your Strapi admin panel, navigate to Settings → Roles → Authenticated and ensure these permissions are enabled for the Summary content type:
Great! Now let's test our update and delete functionality:
The Security Problem We Need to Fix
Our update and delete features work, but we have a major security issue! Currently, when we make a GET request to /api/summaries
, we get all summaries from all users, not just the ones belonging to the logged-in user.
Here's the Problem in Action:
As you can see in the video:
- When I log in as one user, I can see summaries created by other users
- I can potentially edit or delete summaries that don't belong to me
- This is a serious privacy and security concern
What Should Happen:
- Users should only see their own summaries
- Users should only be able to edit/delete their own content
- Attempting to access another user's content should be blocked
Let's fix this security issue by implementing custom Strapi middleware.
Building Secure Middleware in Strapi
Now let's implement the backend security that will ensure users can only access their own content. We'll create custom middleware that automatically filters data based on ownership.
Generating Middleware with Strapi CLI
First, let's use Strapi's built-in generator to create our middleware template. Navigate to your backend project directory and run:
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
We'll name our middleware is-owner
to clearly indicate its purpose, and we'll add it to the root of the project so it can be used across all our content types.
? 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.
Perfect! Strapi has created our middleware file at src/middlewares/is-owner.js
. This file contains a basic template that we'll customize for our needs.
Here's the generated template:
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};
Now let's replace the template code with our ownership logic:
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.userId !== 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, userId: userId },
41 };
42 }
43
44 await next();
45 };
46};
Understanding the Middleware Logic
Our middleware handles two different scenarios:
Scenario 1: Individual Item Access (findOne)
- When
entryId
exists, someone is requesting a specific summary - We check if the summary exists and belongs to the current user
- If it doesn't belong to them, we deny access with an unauthorized error
Scenario 2: List Access (find)
- When
entryId
is missing, someone is requesting a list of summaries - We automatically add a filter to only show summaries owned by the current user
- This ensures users only see their own content without any extra work
Applying Middleware to Routes
Now we need to tell Strapi when to use our middleware. Let's update the summary routes to include our ownership checks.
Navigate to src/api/summary/routes/summary.js
in your Strapi project. You should see the existing route configuration:
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 already have middleware on the create
route from our previous tutorials. Now we need to add our ownership middleware to the other CRUD operations.
Update the file to include middleware for all operations that need ownership checks:
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});
Perfect! Now whenever someone tries to access summaries through any of these routes, our middleware will:
- Check if they're accessing their own content
- Filter results to show only their summaries
- Block unauthorized access attempts
Testing Our Security Implementation
Let's restart our Strapi backend and test our new security measures:
Excellent! Our security is working, but we have a user experience issue. When users try to access a summary that doesn't belong to them, they see a generic error page. Let's improve this.
Improving User Experience with Custom Error Handling
Instead of showing a generic error page, let's create a user-friendly error boundary that gives users clear information and helpful actions.
Next.js makes this easy - we just need to create an error.tsx
file in the route where we want to catch errors.
Create a new file at app/(protected)/dashboard/summaries/[documentId]/error.tsx
:
1"use client"
2
3import { useRouter } from "next/navigation"
4import { RefreshCw, AlertTriangle, ArrowLeft } from "lucide-react"
5
6const styles = {
7 container:
8 "min-h-[calc(100vh-200px)] flex items-center justify-center p-4",
9 content: "max-w-2xl mx-auto text-center space-y-8 bg-gradient-to-br from-red-50 to-orange-50 rounded-xl shadow-lg p-8",
10 textSection: "space-y-4",
11 headingError: "text-8xl font-bold text-red-600 select-none",
12 headingContainer: "relative",
13 pageTitle: "text-4xl font-bold text-gray-900 mb-4",
14 description: "text-lg text-gray-600 max-w-md mx-auto leading-relaxed",
15 illustrationContainer: "flex justify-center py-8",
16 illustration: "relative animate-pulse",
17 errorCircle:
18 "w-24 h-24 bg-red-100 rounded-full flex items-center justify-center transition-all duration-300 hover:bg-red-200",
19 errorIcon: "w-16 h-16 text-red-500",
20 warningBadge:
21 "absolute -top-2 -right-2 w-8 h-8 bg-orange-100 rounded-full flex items-center justify-center animate-bounce",
22 warningSymbol: "text-orange-500 text-xl font-bold",
23 buttonContainer:
24 "flex flex-col sm:flex-row gap-4 justify-center items-center",
25 button: "min-w-[160px] bg-red-600 hover:bg-red-700 text-white",
26 buttonContent: "flex items-center gap-2",
27 buttonIcon: "w-4 h-4",
28 outlineButton: "min-w-[160px] border-red-600 text-red-600 hover:bg-red-50",
29 errorDetails:
30 "mt-8 p-4 bg-red-50 border border-red-200 rounded-lg text-left text-sm text-red-800",
31 errorTitle: "font-semibold mb-2",
32}
33
34interface ErrorPageProps {
35 error: Error & { digest?: string }
36 reset: () => void
37}
38
39export default function ErrorPage({ error, reset }: ErrorPageProps) {
40 const router = useRouter();
41
42 return (
43 <div className={styles.container}>
44 <div className={styles.content}>
45 {/* Large Error Text */}
46 <div className={styles.textSection}>
47 <h1 className={styles.headingError}>Error</h1>
48 <div className={styles.headingContainer}>
49 <h2 className={styles.pageTitle}>Failed to load summaries</h2>
50 <p className={styles.description}>
51 We encountered an error while loading your summaries. This might be a temporary issue.
52 </p>
53 </div>
54 </div>
55
56 {/* Illustration */}
57 <div className={styles.illustrationContainer}>
58 <div className={styles.illustration}>
59 <div className={styles.errorCircle}>
60 <AlertTriangle className={styles.errorIcon} />
61 </div>
62 <div className={styles.warningBadge}>
63 <span className={styles.warningSymbol}>!</span>
64 </div>
65 </div>
66 </div>
67
68 {/* Action Buttons */}
69 <div className={styles.buttonContainer}>
70 <button
71 onClick={reset}
72 className={`${styles.button} px-6 py-3 rounded-lg font-medium transition-colors`}
73 >
74 <div className={styles.buttonContent}>
75 <RefreshCw className={styles.buttonIcon} />
76 Try Again
77 </div>
78 </button>
79
80 <button
81 onClick={() => router.back()}
82 className={`${styles.outlineButton} px-6 py-3 rounded-lg font-medium border-2 transition-colors inline-flex`}
83 >
84 <div className={styles.buttonContent}>
85 <ArrowLeft className={styles.buttonIcon} />
86 Go Back
87 </div>
88 </button>
89 </div>
90
91 {process.env.NODE_ENV === "development" && (
92 <div className={styles.errorDetails}>
93 <div className={styles.errorTitle}>
94 Error Details (Development Only):
95 </div>
96 <div>Message: {error.message}</div>
97 {error.digest && <div>Digest: {error.digest}</div>}
98 {error.stack && (
99 <details className="mt-2">
100 <summary className="cursor-pointer font-medium">
101 Stack Trace
102 </summary>
103 <pre className="mt-2 text-xs overflow-auto">
104 {error.stack}
105 </pre>
106 </details>
107 )}
108 </div>
109 )}
110 </div>
111 </div>
112 )
113}
This custom error page will now handle authorization errors gracefully instead of showing the generic global error page.
Let's test it by trying to access a summary that belongs to another user:
Much better! Now users get a clear, helpful error page with options to try again or go back, rather than a confusing generic error.
What We've Accomplished
Congratulations! We've built a comprehensive, secure system for managing video summaries. Let's review what we've implemented:
Frontend Achievements
- Modern Server Actions: Implemented update and delete functionality using Next.js 15's server actions
- Robust Validation: Added client and server-side validation with Zod schemas
- Great User Experience: Form state preservation, success messages, and clear error feedback
- Clean Architecture: Separated concerns between validation, services, actions, and UI components
Backend Security
- Custom Middleware: Created ownership-checking middleware that runs on every request
- Automatic Filtering: Users automatically see only their own content
- Access Control: Prevents users from accessing or modifying others' summaries
- Route Protection: Applied middleware to all CRUD operations
User Experience Enhancements
- Custom Error Pages: Friendly error handling instead of generic error messages
- Clear Feedback: Users know exactly what went wrong and how to fix it
- Smooth Interactions: Updates work instantly, deletes redirect appropriately
Technical Benefits
- Scalable Security: Middleware works for any content type, not just summaries
- Type Safety: Full TypeScript coverage throughout the application
- Maintainable Code: Following established patterns makes future development easier
- Production Ready: Proper error handling and security make this deployment-ready
Next Steps
In our next tutorial, we'll explore advanced features like search functionality and pagination to handle large amounts of summary data efficiently.
Thanks for following along with this tutorial! You now have a solid foundation for building secure, user-specific CRUD operations in your Next.js applications.
Note about this project
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.
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!
Paul