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.
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.
Before diving into implementation, let's review the four essential database operations that power our application:
The Four CRUD Operations:
Strapi automatically creates RESTful API endpoints for our summary content type:
POST /api/summaries
GET /api/summaries
GET /api/summaries/:id
PUT /api/summaries/:id
DELETE /api/summaries/:id
These endpoints form the backbone of our application's data operations.
While Strapi provides basic authentication through JWT tokens, there's an important distinction we need to understand:
Authentication vs Authorization:
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.
Think of route middleware as a security guard at a building entrance. Every request must pass through this checkpoint before accessing your data.
The Middleware Process:
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.
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.
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.
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};
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}
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},
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}
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};
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:
useActionState
for both update and delete operationsZodErrors
component for field-specific errorsdocumentId
to both formsDon'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:
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}
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:
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:
What Should Happen:
Let's fix this security issue by implementing custom Strapi middleware.
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.
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};
Our middleware handles two different scenarios:
Scenario 1: Individual Item Access (findOne)
entryId
exists, someone is requesting a specific summaryScenario 2: List Access (find)
entryId
is missing, someone is requesting a list of summariesNow 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:
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.
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.
Congratulations! We've built a comprehensive, secure system for managing video summaries. Let's review what we've implemented:
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.
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