In the previous tutorial, we completed our authentication system with Sign In and Sign Up functionality. Now we'll focus on building out our Dashboard and Profile Page with file upload capabilities using Next.js server actions.
- 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 7: Strapi CRUD permissions
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
Welcome to Part 5 of our Epic Next.js tutorial series! In the previous tutorial, we implemented authentication using HTTPOnly cookies and protected our routes with Next.js middleware.
In this section, we'll complete our Dashboard and Profile Page by implementing file upload functionality using Next.js server actions. By the end of this tutorial, you'll have a solid understanding of handling file uploads, forms, and server actions in Next.js 15.
Currently, our Dashboard Page looks like the following.
Let's start by creating a proper dashboard layout. Navigate to src/app/(protected)dashboard
, create a file called layout.tsx
, and add the following code:
1import Link from "next/link";
2import { SVGProps } from "react";
3
4const styles = {
5 layout: "h-screen grid grid-cols-[240px_1fr]",
6 sidebar: "border-r bg-gray-100/40 dark:bg-gray-800/40",
7 sidebarContent: "flex h-full max-h-screen flex-col gap-2",
8 header: "flex h-[60px] items-center border-b px-6",
9 headerLink: "flex items-center gap-2 font-semibold",
10 headerIcon: "h-6 w-6",
11 headerText: "",
12 navigation: "flex-1 overflow-auto py-2",
13 navGrid: "grid items-start px-4 text-sm font-medium",
14 navLink:
15 "flex items-center gap-3 rounded-lg px-3 py-2 text-gray-500 transition-all hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-50",
16 navIcon: "h-4 w-4",
17 main: "flex flex-col overflow-scroll",
18};
19
20export default function DashboardLayout({
21 children,
22}: {
23 readonly children: React.ReactNode;
24}) {
25 return (
26 <div className={styles.layout}>
27 <nav className={styles.sidebar}>
28 <div className={styles.sidebarContent}>
29 <div className={styles.header}>
30 <Link className={styles.headerLink} href="/dashboard">
31 <LayoutDashboardIcon className={styles.headerIcon} />
32 <span className={styles.headerText}>Dashboard</span>
33 </Link>
34 </div>
35 <div className={styles.navigation}>
36 <nav className={styles.navGrid}>
37 <Link className={styles.navLink} href="/dashboard/summaries">
38 <ViewIcon className={styles.navIcon} />
39 Summaries
40 </Link>
41
42 <Link className={styles.navLink} href="/dashboard/account">
43 <UsersIcon className={styles.navIcon} />
44 Account
45 </Link>
46 </nav>
47 </div>
48 </div>
49 </nav>
50 <main className={styles.main}>{children}</main>
51 </div>
52 );
53}
54
55function LayoutDashboardIcon(props: SVGProps<SVGSVGElement>) {
56 return (
57 <svg
58 {...props}
59 xmlns="http://www.w3.org/2000/svg"
60 width="24"
61 height="24"
62 viewBox="0 0 24 24"
63 fill="none"
64 stroke="currentColor"
65 strokeWidth="2"
66 strokeLinecap="round"
67 strokeLinejoin="round"
68 >
69 <rect width="7" height="9" x="3" y="3" rx="1" />
70 <rect width="7" height="5" x="14" y="3" rx="1" />
71 <rect width="7" height="9" x="14" y="12" rx="1" />
72 <rect width="7" height="5" x="3" y="16" rx="1" />
73 </svg>
74 );
75}
76
77function PieChartIcon(props: SVGProps<SVGSVGElement>) {
78 return (
79 <svg
80 {...props}
81 xmlns="http://www.w3.org/2000/svg"
82 width="24"
83 height="24"
84 viewBox="0 0 24 24"
85 fill="none"
86 stroke="currentColor"
87 strokeWidth="2"
88 strokeLinecap="round"
89 strokeLinejoin="round"
90 >
91 <path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
92 <path d="M22 12A10 10 0 0 0 12 2v10z" />
93 </svg>
94 );
95}
96
97function UsersIcon(props: SVGProps<SVGSVGElement>) {
98 return (
99 <svg
100 {...props}
101 xmlns="http://www.w3.org/2000/svg"
102 width="24"
103 height="24"
104 viewBox="0 0 24 24"
105 fill="none"
106 stroke="currentColor"
107 strokeWidth="2"
108 strokeLinecap="round"
109 strokeLinejoin="round"
110 >
111 <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
112 <circle cx="9" cy="7" r="4" />
113 <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
114 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
115 </svg>
116 );
117}
118
119function ViewIcon(props: SVGProps<SVGSVGElement>) {
120 return (
121 <svg
122 {...props}
123 xmlns="http://www.w3.org/2000/svg"
124 width="24"
125 height="24"
126 viewBox="0 0 24 24"
127 fill="none"
128 stroke="currentColor"
129 strokeWidth="2"
130 strokeLinecap="round"
131 strokeLinejoin="round"
132 >
133 <path d="M5 12s2.545-5 7-5c4.454 0 7 5 7 5s-2.546 5-7 5c-4.455 0-7-5-7-5z" />
134 <path d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" />
135 <path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2" />
136 <path d="M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2" />
137 </svg>
138 );
139}
After adding this layout, your dashboard UI should look like the following:
Updating Top Header To Include Username and Logout Button
Currently, our Top Header doesn't display information about the logged-in user. Let's update it to show the username and provide a logout option.
Navigate to src/components/custom/header.tsx
and make the following changes.
First, let's import our authentication service that we created in the previous tutorial to retrieve the logged-in user's data:
1import { services } from "@/data/services";
Next, let's call this service inside our Header component:
1const user = await services.auth.getUserMeService();
2console.log(user);
If you're logged in, you should see your user data in the console:
1{
2 success: true,
3 data: {
4 id: 7,
5 documentId: 'wq9a4sm2kmw6l9n1tn8mav8t',
6 username: 'testuser',
7 email: 'testuser@email.com',
8 provider: 'local',
9 confirmed: true,
10 blocked: false,
11 createdAt: '2025-08-15T19:40:58.276Z',
12 updatedAt: '2025-08-15T19:40:58.276Z',
13 publishedAt: '2025-08-15T19:40:58.284Z'
14 },
15 error: undefined,
16 status: 200
17}
We can use the success
key to conditionally render either our Sign Up
button or the user's name and logout button.
Let's create a LoggedInUser component to handle the logged-in user display. Create a new file src/components/custom/logged-in-user.tsx
:
1import Link from "next/link";
2import { LogoutButton } from "./logout-button";
3
4interface ILoggedInUserProps {
5 username: string;
6 email: string;
7}
8
9export function LoggedInUser({
10 userData,
11}: {
12 readonly userData: ILoggedInUserProps;
13}) {
14 return (
15 <div className="flex gap-2">
16 <Link
17 href="/dashboard/account"
18 className="font-semibold hover:text-primary"
19 >
20 {userData.username}
21 </Link>
22 <LogoutButton />
23 </div>
24 );
25}
Now, let's update our header.tsx
file. Find this section:
1<div className={styles.actions}>
2 <Link href={ctaButton.href}>
3 <Button>{ctaButton.label}</Button>
4 </Link>
5</div>
Replace it with the conditional rendering logic:
1<div className={styles.actions}>
2 {user.status ? (
3 <LoggedInUser userData={user.data!} />
4 ) : (
5 <Link href={ctaButton.href}>
6 <Button>{ctaButton.label}</Button>
7 </Link>
8 )}
9</div>
Don't forget to import LoggedInUser.
1import { LoggedInUser } from "@/components/custom/logged-in-user";
The completed code in our header.tsx
file should look like the following.
1import Link from "next/link";
2import type { THeader } from "@/types";
3
4import { services } from "@/data/services";
5
6import { Logo } from "@/components/custom/logo";
7import { Button } from "@/components/ui/button";
8import { LoggedInUser } from "@/components/custom/logged-in-user";
9
10const styles = {
11 header:
12 "flex items-center justify-between px-4 py-3 bg-white shadow-md dark:bg-gray-800",
13 actions: "flex items-center gap-4",
14 summaryContainer: "flex-1 flex justify-center max-w-2xl mx-auto",
15};
16
17interface IHeaderProps {
18 data?: THeader | null;
19}
20
21export async function Header({ data }: IHeaderProps) {
22 if (!data) return null;
23
24 const user = await services.auth.getUserMeService();
25 const { logoText, ctaButton } = data;
26 return (
27 <div className={styles.header}>
28 <Logo text={logoText.label} />
29 <div className={styles.actions}>
30 {user.success && user.data ? (
31 <LoggedInUser userData={user.data} />
32 ) : (
33 <Link href={ctaButton.href}>
34 <Button>{ctaButton.label}</Button>
35 </Link>
36 )}
37 </div>
38 </div>
39 );
40}
Perfect! Now when you're logged in, you should see the username and logout button in the header:
Let's make one more improvement to our hero-section.tsx
file in the src/components/custom
folder.
One of the great features of React Server Components is that they can handle their own data fetching. Let's update the hero section so that logged-in users see a "Dashboard" button instead of the default call-to-action.
Here's the updated hero section code:
1import Link from "next/link";
2import { services } from "@/data/services";
3import type { TImage, TLink } from "@/types";
4
5import { StrapiImage } from "./strapi-image";
6
7export interface IHeroSectionProps {
8 id: number;
9 documentId: string;
10 __component: string;
11 heading: string;
12 subHeading: string;
13 image: TImage;
14 link: TLink;
15}
16
17const styles = {
18 header: "relative h-[600px] overflow-hidden",
19 backgroundImage: "absolute inset-0 object-cover w-full h-full",
20 overlay:
21 "relative z-10 flex flex-col items-center justify-center h-full text-center text-white bg-black/50",
22 heading: "text-4xl font-bold md:text-5xl lg:text-6xl",
23 subheading: "mt-4 text-lg md:text-xl lg:text-2xl",
24 button:
25 "mt-8 inline-flex items-center justify-center px-6 py-3 text-base font-medium text-black bg-white rounded-md shadow hover:bg-gray-100 transition-colors",
26};
27
28export async function HeroSection({ data }: { data: IHeroSectionProps }) {
29 if (!data) return null;
30 const user = await services.auth.getUserMeService();
31 const userLoggedIn = user.success;
32
33 const { heading, subHeading, image, link } = data;
34 return (
35 <header className={styles.header}>
36 <StrapiImage
37 alt={image.alternativeText ?? "no alternative text"}
38 className="absolute inset-0 object-cover w-full h-full aspect/16:9"
39 src={image.url}
40 height={1080}
41 width={1920}
42 />
43 <div className={styles.overlay}>
44 <h1 className={styles.heading}>{heading}</h1>
45 <p className={styles.subheading}>{subHeading}</p>
46 <Link
47 className={styles.button}
48 href={userLoggedIn ? "/dashboard" : link.href}
49 >
50 {userLoggedIn ? "Dashboard" : link.label}
51 </Link>
52 </div>
53 </header>
54 );
55}
Now when a user is logged in, the Hero Section will display a "Dashboard" button:
Now let's build our Account page where users can manage their profile information.
Creating Our User Profile Page (Account Page)
Let's create the account page structure. Navigate to the dashboard
folder and create an account
folder with a page.tsx
file inside.
Add the following code to create a basic account page structure:
1import { services } from "@/data/services";
2import { validateApiResponse } from "@/lib/error-handler";
3// import { ProfileForm } from "@/components/forms/profile-form";
4// import { ProfileImageForm } from "@/components/forms/profile-image-form";
5
6export default async function AccountRoute() {
7 const user = await services.auth.getUserMeService();
8 const userData = validateApiResponse(user, "user profile");
9 const userImage = userData?.image;
10
11 return (
12 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
13 {/* <ProfileForm user={userData} className="col-span-3" /> */}
14 {/* <ProfileImageForm image={userImage} className="col-span-2" /> */}
15 </div>
16 );
17}
I've commented out the components we haven't created yet so the app can render without errors. Next, we'll build the ProfileForm and ProfileImageForm components.
Create a Form To Update User's Details
Navigate to src/components/forms
and create a profile-form.tsx
file.
Add the following code to create our profile form component:
1"use client";
2import React from "react";
3import { cn } from "@/lib/utils";
4
5import type { TAuthUser } from "@/types";
6
7import { SubmitButton } from "@/components/custom/submit-button";
8import { Input } from "@/components/ui/input";
9import { Textarea } from "@/components/ui/textarea";
10
11interface IProfileFormProps {
12 user?: TAuthUser | null;
13}
14
15const styles = {
16 form: "space-y-4",
17 container: "space-y-4 grid",
18 topRow: "grid grid-cols-3 gap-4",
19 nameRow: "grid grid-cols-2 gap-4",
20 fieldGroup: "space-y-2",
21 textarea: "resize-none border rounded-md w-full h-[224px] p-2",
22 buttonContainer: "flex justify-end",
23 countBox:
24 "flex items-center justify-center h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none",
25 creditText: "font-bold text-md mx-1",
26};
27
28export function ProfileForm({
29 user,
30 className,
31}: IProfileFormProps & {
32 readonly className?: string;
33}) {
34 if (!user) {
35 return (
36 <div className={cn(styles.form, className)}>
37 <p>Unable to load profile data</p>
38 </div>
39 );
40 }
41
42 return (
43 <form className={cn(styles.form, className)}>
44 <div className={styles.container}>
45 <div className={styles.topRow}>
46 <Input
47 id="username"
48 name="username"
49 placeholder="Username"
50 defaultValue={user.username || ""}
51 disabled
52 />
53 <Input
54 id="email"
55 name="email"
56 placeholder="Email"
57 defaultValue={user.email || ""}
58 disabled
59 />
60 <CountBox text={user.credits || 0} />
61 </div>
62
63 <div className={styles.nameRow}>
64 <div className={styles.fieldGroup}>
65 <Input
66 id="firstName"
67 name="firstName"
68 placeholder="First Name"
69 defaultValue={user.firstName || ""}
70 />
71 </div>
72 <div className={styles.fieldGroup}>
73 <Input
74 id="lastName"
75 name="lastName"
76 placeholder="Last Name"
77 defaultValue={user.lastName || ""}
78 />
79 </div>
80 </div>
81 <div className={styles.fieldGroup}>
82 <Textarea
83 id="bio"
84 name="bio"
85 placeholder="Write your bio here..."
86 className={styles.textarea}
87 defaultValue={user.bio || ""}
88 />
89 </div>
90 </div>
91 <div className={styles.buttonContainer}>
92 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
93 </div>
94 </form>
95 );
96}
97
98function CountBox({ text }: { text: number }) {
99 const color = text > 0 ? "text-primary" : "text-red-500";
100 return (
101 <div className={styles.countBox}>
102 You have<span className={cn(styles.creditText, color)}>{text}</span>
103 credit(s)
104 </div>
105 );
106}
Since we're using a new Shadcn UI component (Textarea
), let's install it:
npx shadcn@latest add textarea
Let's uncomment our ProfileForm in our dashboard/account/page.tsx
file.
1import { services } from "@/data/services";
2import { validateApiResponse } from "@/lib/error-handler";
3import { ProfileForm } from "@/components/forms/profile-form";
4// import { ProfileImageForm } from "@/components/forms/profile-image-form";
5
6export default async function AccountRoute() {
7 const user = await services.auth.getUserMeService();
8 const userData = validateApiResponse(user, "user profile");
9 const userImage = userData?.image;
10
11 return (
12 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
13 <ProfileForm user={userData} className="col-span-3" />
14 {/* <ProfileImageForm image={userImage} className="col-span-2" /> */}
15 </div>
16 );
17}
Restart your Next.js development server, and you should see the following:
You'll notice two issues with the current form:
We're not displaying the user's First Name, Last Name, Bio, or Credits because these fields don't exist in our Strapi user schema yet.
The form can't be submitted because we haven't implemented the server action logic yet.
Let's address the first issue by updating our user schema in Strapi, then we'll implement the server action functionality.
Updating User Data Schema In Our Backend
In your Strapi Admin area, navigate to the Content-Type Builder and select the User collection type:
Add the following fields to extend the User collection type:
Name | Field | Type | Advanced Settings |
---|---|---|---|
firstName | Text | Short Text | |
lastName | Text | Short Text | |
bio | Text | Long Text | |
credits | Number | Integer | Set default value to be 0 |
For the credits field, we'll set a default value of 0
that new users will start with. You can configure this by clicking the advanced settings button and filling in the default value field.
Once you've added all the fields, your User collection type should look like this:
Now let's manually update our user's information in Strapi to test if the data appears correctly in our frontend:
After updating the user data, navigate to your Account page in the frontend. You should now see the updated information:
Now let's implement the form update functionality using server actions.
Updating User Data With Server Actions
First, let's create the updateProfileAction
that will handle our form submission.
Navigate to src/data/actions
, create a new file called profile.ts
, and add the following code:
1"use server";
2import { z } from "zod";
3
4import { services } from "@/data/services";
5
6import {
7 ProfileFormSchema,
8 type ProfileFormState,
9} from "@/data/validation/profile";
10
11export async function updateProfileAction(
12 prevState: ProfileFormState,
13 formData: FormData
14): Promise<ProfileFormState> {
15 console.log("Hello From Login User Action");
16
17 const fields = Object.fromEntries(formData);
18
19 console.dir(fields);
20
21 const validatedFields = ProfileFormSchema.safeParse(fields);
22
23 if (!validatedFields.success) {
24 const flattenedErrors = z.flattenError(validatedFields.error);
25 console.log("Validation failed:", flattenedErrors.fieldErrors);
26 return {
27 success: false,
28 message: "Validation failed",
29 strapiErrors: null,
30 zodErrors: flattenedErrors.fieldErrors,
31 data: {
32 ...prevState.data,
33 ...fields,
34 },
35 };
36 }
37
38 console.log("Validation successful:", validatedFields.data);
39
40 const responseData = await services.profile.updateProfileService(
41 validatedFields.data
42 );
43
44 if (!responseData) {
45 return {
46 success: false,
47 message: "Ops! Something went wrong. Please try again.",
48 strapiErrors: null,
49 zodErrors: null,
50 data: {
51 ...prevState.data,
52 ...fields,
53 },
54 };
55 }
56
57 if (responseData.error) {
58 return {
59 success: false,
60 message: "Failed to Login.",
61 strapiErrors: responseData.error,
62 zodErrors: null,
63 data: {
64 ...prevState.data,
65 ...fields,
66 },
67 };
68 }
69
70 console.log("#############");
71 console.log("User Login Successfully", responseData);
72 console.log("#############");
73
74 return {
75 success: false,
76 message: "Successfully updated form",
77 strapiErrors: null,
78 zodErrors: null,
79 data: {
80 ...prevState.data,
81 ...fields,
82 },
83 };
84}
And don't forget to export it from our index.ts
file:
1import {
2 registerUserAction,
3 loginUserAction,
4 logoutUserAction,
5 getAuthTokenAction,
6} from "./auth";
7import { updateProfileAction } from "./profile";
8
9export const actions = {
10 auth: {
11 registerUserAction,
12 loginUserAction,
13 logoutUserAction,
14 getAuthTokenAction,
15 },
16 profile: {
17 updateProfileAction,
18 },
19};
We've created similar actions before, so this pattern should be familiar. The action expects three things we need to create:
updateProfileService
- handles the API call to StrapiProfileFormSchema
- Zod schema for validationProfileFormState
type - TypeScript type for form state
Let's start with the Zod validation schema, which will ensure data integrity before sending it to our backend.
Create a profile.ts
file inside the validation
folder and add the following validation schemas:
1import { z } from "zod";
2
3export const ProfileFormSchema = z.object({
4 firstName: z
5 .string()
6 .min(1, "First name is required")
7 .max(50, "First name must be less than 50 characters"),
8 lastName: z
9 .string()
10 .min(1, "Last name is required")
11 .max(50, "Last name must be less than 50 characters"),
12 bio: z
13 .string()
14 .min(10, "Bio must be at least 10 characters")
15 .max(500, "Bio must be less than 500 characters"),
16});
17
18export type ProfileFormValues = z.infer<typeof ProfileFormSchema>;
19
20export type ProfileFormState = {
21 success?: boolean;
22 message?: string;
23 data?: {
24 firstName?: string;
25 lastName?: string;
26 bio?: string;
27 };
28 strapiErrors?: {
29 status: number;
30 name: string;
31 message: string;
32 details?: Record<string, string[]>;
33 } | null;
34 zodErrors?: {
35 firstName?: string[];
36 lastName?: string[];
37 bio?: string[];
38 } | null;
39};
40
41export const ProfileImageFormSchema = z.object({
42 image: z
43 .instanceof(File)
44 .refine((file) => file.size > 0, "Image is required")
45 .refine((file) => file.size <= 5000000, "Image must be less than 5MB")
46 .refine(
47 (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
48 "Image must be JPEG, PNG, or WebP format"
49 ),
50});
51
52export type ProfileImageFormValues = z.infer<typeof ProfileImageFormSchema>;
53
54export type ProfileImageFormState = {
55 success?: boolean;
56 message?: string;
57 data?: {
58 image?: File;
59 };
60 strapiErrors?: {
61 status: number;
62 name: string;
63 message: string;
64 details?: Record<string, string[]>;
65 } | null;
66 zodErrors?: {
67 image?: string[];
68 } | null;
69};
Now let's create the updateProfileService
method. In the services
folder, create a file named profile.ts
and add the following code:
1import { getStrapiURL } from "@/lib/utils";
2import type { TStrapiResponse, TAuthUser } from "@/types";
3import { services } from "@/data/services";
4import { actions } from "@/data/actions";
5import { api } from "@/data/data-api";
6
7type TUpdateProfile = {
8 firstName: string;
9 lastName: string;
10 bio: string;
11};
12
13const baseUrl = getStrapiURL();
14
15export async function updateProfileService(
16 profileData: TUpdateProfile
17): Promise<TStrapiResponse<TAuthUser>> {
18 const userId = (await services.auth.getUserMeService()).data?.id;
19 if (!userId) throw new Error("User Id is required");
20
21 const authToken = await actions.auth.getAuthTokenAction();
22 if (!authToken) throw new Error("You are not authorized");
23
24 const url = new URL("/api/users/" + userId, baseUrl);
25 const result = await api.put<TAuthUser, TUpdateProfile>(
26 url.href,
27 profileData,
28 { authToken }
29 );
30
31 console.log("######### actual profile update response");
32 console.dir(result, { depth: null });
33
34 return result;
35}
And finally let's add it to our export in the index.ts
file so we can use it:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6
7import { updateProfileService } from "./profile";
8
9export const services = {
10 auth: {
11 registerUserService,
12 loginUserService,
13 getUserMeService,
14 },
15 profile: {
16 updateProfileService,
17 },
18};
Perfect! Now we have all the pieces needed to implement the profile update functionality.
Let's update our profile-form.tsx
file to connect it with our server action:
First import action
, useActionState
and our ProfileFormState
:
1import { actions } from "@/data/actions";
2import { useActionState } from "react";
3import type { ProfileFormState } from "@/data/validation/profile";
Next, let's import our ZodErrors
and StrapiErrors
components so we can use them in our form:
1import { ZodErrors } from "@/components/custom/zod-errors";
2import { StrapiErrors } from "@/components/custom/strapi-errors";
Now, let's define our initial form state:
1const INITIAL_STATE: ProfileFormState = {
2 success: false,
3 message: undefined,
4 strapiErrors: null,
5 zodErrors: null,
6};
Once we have that, we can now define our action state:
1const [formState, formAction] = useActionState(
2 actions.profile.updateProfileAction,
3 INITIAL_STATE
4);
Now that we have our action we will update our form
tag with the following.
1 <form action={formAction} className={cn(styles.form, className)}>
Then we will add our ZodError for the fields that we are going to update.
1<div className={styles.nameRow}>
2 <div className={styles.fieldGroup}>
3 <Input
4 id="firstName"
5 name="firstName"
6 placeholder="First Name"
7 defaultValue={formState?.data?.firstName || user.firstName || ""}
8 />
9 <ZodErrors error={formState?.zodErrors?.firstName} />
10 </div>
11 <div className={styles.fieldGroup}>
12 <Input
13 id="lastName"
14 name="lastName"
15 placeholder="Last Name"
16 defaultValue={formState?.data?.lastName || user.lastName || ""}
17 />
18 <ZodErrors error={formState?.zodErrors?.lastName} />
19 </div>
20</div>
21 <div className={styles.fieldGroup}>
22 <Textarea
23 id="bio"
24 name="bio"
25 placeholder="Write your bio here..."
26 className={styles.textarea}
27 defaultValue={formState?.data?.bio || user.bio || ""}
28 />
29 <ZodErrors error={formState?.zodErrors?.bio} />
30 </div>
Notice we are also updating our defaultValue to use our formState data.
Finally we will add our Strapi Errors component:
1<div className={styles.buttonContainer}>
2 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
3 <StrapiErrors error={formState?.strapiErrors} />
4</div>
The final code should look like the following inside your profile-form.tsx
file.
1"use client";
2import React from "react";
3import { cn } from "@/lib/utils";
4
5import { actions } from "@/data/actions";
6import { useActionState } from "react";
7import type { ProfileFormState } from "@/data/validation/profile";
8
9import type { TAuthUser } from "@/types";
10
11import { SubmitButton } from "@/components/custom/submit-button";
12import { Input } from "@/components/ui/input";
13import { Textarea } from "@/components/ui/textarea";
14
15import { ZodErrors } from "@/components/custom/zod-errors";
16import { StrapiErrors } from "@/components/custom/strapi-errors";
17
18const styles = {
19 form: "space-y-4",
20 container: "space-y-4 grid",
21 topRow: "grid grid-cols-3 gap-4",
22 nameRow: "grid grid-cols-2 gap-4",
23 fieldGroup: "space-y-2",
24 textarea: "resize-none border rounded-md w-full h-[224px] p-2",
25 buttonContainer: "flex justify-end",
26 countBox:
27 "flex items-center justify-center h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none",
28 creditText: "font-bold text-md mx-1",
29};
30
31interface IProfileFormProps {
32 user?: TAuthUser | null;
33}
34
35const INITIAL_STATE: ProfileFormState = {
36 success: false,
37 message: undefined,
38 strapiErrors: null,
39 zodErrors: null,
40};
41
42export function ProfileForm({
43 user,
44 className,
45}: IProfileFormProps & {
46 readonly className?: string;
47}) {
48 const [formState, formAction] = useActionState(
49 actions.profile.updateProfileAction,
50 INITIAL_STATE
51 );
52 if (!user) {
53 return (
54 <div className={cn(styles.form, className)}>
55 <p>Unable to load profile data</p>
56 </div>
57 );
58 }
59
60 return (
61 <form action={formAction} className={cn(styles.form, className)}>
62 <div className={styles.container}>
63 <div className={styles.topRow}>
64 <Input
65 id="username"
66 name="username"
67 placeholder="Username"
68 defaultValue={user.username || ""}
69 disabled
70 />
71 <Input
72 id="email"
73 name="email"
74 placeholder="Email"
75 defaultValue={user.email || ""}
76 disabled
77 />
78 <CountBox text={user.credits || 0} />
79 </div>
80
81 <div className={styles.nameRow}>
82 <div className={styles.fieldGroup}>
83 <Input
84 id="firstName"
85 name="firstName"
86 placeholder="First Name"
87 defaultValue={formState?.data?.firstName || user.firstName || ""}
88 />
89 <ZodErrors error={formState?.zodErrors?.firstName} />
90 </div>
91 <div className={styles.fieldGroup}>
92 <Input
93 id="lastName"
94 name="lastName"
95 placeholder="Last Name"
96 defaultValue={formState?.data?.lastName || user.lastName || ""}
97 />
98 <ZodErrors error={formState?.zodErrors?.lastName} />
99 </div>
100 </div>
101 <div className={styles.fieldGroup}>
102 <Textarea
103 id="bio"
104 name="bio"
105 placeholder="Write your bio here..."
106 className={styles.textarea}
107 defaultValue={formState?.data?.bio || user.bio || ""}
108 />
109 <ZodErrors error={formState?.zodErrors?.bio} />
110 </div>
111 </div>
112 <div className={styles.buttonContainer}>
113 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
114 </div>
115 <StrapiErrors error={formState?.strapiErrors} />
116 </form>
117 );
118}
119
120function CountBox({ text }: { text: number }) {
121 const color = text > 0 ? "text-primary" : "text-red-500";
122 return (
123 <div className={styles.countBox}>
124 You have<span className={cn(styles.creditText, color)}>{text}</span>
125 credit(s)
126 </div>
127 );
128}
You might be wondering: "How does the system know which user to update?"
Looking at our updateProfileService
, you'll see how we handle this:
1export async function updateProfileService(
2 profileData: TUpdateProfile
3): Promise<TStrapiResponse<TAuthUser>> {
4 const userId = (await services.auth.getUserMeService()).data?.id;
5 if (!userId) throw new Error("User Id is required");
6
7 const authToken = await actions.auth.getAuthTokenAction();
8 if (!authToken) throw new Error("You are not authorized");
9
10 const url = new URL("/api/users/" + userId, baseUrl);
11 const result = await api.put<TAuthUser, TUpdateProfile>(
12 url.href,
13 profileData,
14 { authToken }
15 );
16
17 console.log("######### actual profile update response");
18 console.dir(result, { depth: null });
19
20 return result;
21}
You will see that we are getting our logged in user from Strapi and checking if they exists.
1const userId = (await services.auth.getUserMeService()).data?.id;
2if (!userId) throw new Error("User Id is required");
Now let's test our Profile Form. Before it works, we need to configure the proper permissions in Strapi.
To allow authenticated users to update their profiles, we need to grant the appropriate permissions in Strapi's admin panel:
Important Security Note: In a production application, you should implement additional policies to ensure users can only update their own profile data. We'll cover advanced security patterns in a future tutorial.
Let's try to update our profile and see if it works.
Excellent! Now that we can update profile information, let's implement file upload functionality.
Implementing File Upload with Next.js Server Actions
File upload is an important part of every applications. Let's build a user-friendly image picker component first.
Navigate to src/components/custom
, create a file called image-picker.tsx
, and add the following code:
1"use client";
2import React, { useState, useRef } from "react";
3import { StrapiImage } from "./strapi-image";
4
5import { Input } from "@/components/ui/input";
6import { Label } from "@/components/ui/label";
7
8interface ImagePickerProps {
9 id: string;
10 name: string;
11 label: string;
12 showCard?: boolean;
13 defaultValue?: string;
14 onChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
15}
16
17function generateDataUrl(file: File, callback: (imageUrl: string) => void) {
18 const reader = new FileReader();
19 reader.onload = () => callback(reader.result as string);
20 reader.readAsDataURL(file);
21}
22
23function ImagePreview({ dataUrl }: { readonly dataUrl: string }) {
24 return (
25 <StrapiImage
26 src={dataUrl}
27 alt="preview"
28 height={200}
29 width={200}
30 className="rounded-lg w-full object-cover"
31 />
32 );
33}
34
35function ImageCard({
36 dataUrl,
37 fileInput,
38}: {
39 readonly dataUrl: string;
40 readonly fileInput: React.RefObject<HTMLInputElement | null>;
41}) {
42 const imagePreview = dataUrl ? (
43 <ImagePreview dataUrl={dataUrl} />
44 ) : (
45 <p>No image selected</p>
46 );
47
48 return (
49 <div className="w-full relative">
50 <div className=" flex items-center space-x-4 rounded-md border p-4">
51 {imagePreview}
52 </div>
53 <button
54 onClick={() => fileInput.current?.click()}
55 className="w-full absolute inset-0"
56 type="button"
57 ></button>
58 </div>
59 );
60}
61
62export default function ImagePicker({
63 id,
64 name,
65 label,
66 defaultValue,
67}: Readonly<ImagePickerProps>) {
68 const fileInput = useRef<HTMLInputElement>(null);
69 const [dataUrl, setDataUrl] = useState<string | null>(defaultValue ?? null);
70
71 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
72 const file = e.target.files?.[0];
73 if (file) generateDataUrl(file, setDataUrl);
74 };
75
76 return (
77 <React.Fragment>
78 <div className="hidden">
79 <Label htmlFor={name}>{label}</Label>
80 <Input
81 type="file"
82 id={id}
83 name={name}
84 onChange={handleFileChange}
85 ref={fileInput}
86 accept="image/*"
87 />
88 </div>
89 <ImageCard dataUrl={dataUrl ?? ""} fileInput={fileInput} />
90 </React.Fragment>
91 );
92}
This component provides a clean interface for users to select and preview images before uploading.
Now let's create the ProfileImageForm component that uses our ImagePicker.
Navigate to src/components/forms
, create a file called profile-image-form.tsx
, and add the following code:
1"use client";
2import React from "react";
3import { useActionState } from "react";
4import { cn } from "@/lib/utils";
5import { actions } from "@/data/actions";
6import { type ProfileImageFormState } from "@/data/validation/profile";
7import { TImage } from "@/types";
8
9import { SubmitButton } from "@/components/custom/submit-button";
10import ImagePicker from "@/components/custom/image-picker";
11import { ZodErrors } from "@/components/custom/zod-errors";
12import { StrapiErrors } from "@/components/custom/strapi-errors";
13
14interface IProfileImageFormProps {
15 image?: TImage | null;
16}
17
18const INITIAL_STATE: ProfileImageFormState = {
19 success: false,
20 message: undefined,
21 strapiErrors: null,
22 zodErrors: null,
23};
24
25export function ProfileImageForm({
26 image,
27 className,
28}: IProfileImageFormProps & {
29 className?: string;
30}) {
31 const [formState, formAction] = useActionState(
32 actions.profile.updateProfileImageAction,
33 INITIAL_STATE
34 );
35
36 return (
37 <form action={formAction} className={cn("space-y-4", className)}>
38 <div className="space-y-2">
39 <input
40 hidden
41 id="id"
42 name="id"
43 defaultValue={image?.documentId || ""}
44 />
45 <ImagePicker
46 id="image"
47 name="image"
48 label="Profile Image"
49 defaultValue={image?.url || ""}
50 />
51 <ZodErrors error={formState?.zodErrors?.image} />
52 <StrapiErrors error={formState?.strapiErrors} />
53 </div>
54 <div className="flex justify-end">
55 <SubmitButton text="Update Image" loadingText="Saving Image" />
56 </div>
57 </form>
58 );
59}
Since we are just repeating our patterns, most of the items here already exist. We just have to add updateProfileImageAction
and helper functions to allow us to do this. As well as our updateProfileImageService
Let's start by creating our action. In the actions/profile.ts
file let's add the following:
1export async function updateProfileImageAction(
2 prevState: ProfileImageFormState,
3 formData: FormData
4): Promise<ProfileImageFormState> {
5 console.log("Hello From Update Profile Image Action");
6
7 // Get current user
8 const user = await services.auth.getUserMeService();
9 if (!user.success || !user.data) {
10 return {
11 success: false,
12 message: "You are not authorized to perform this action.",
13 strapiErrors: null,
14 zodErrors: null,
15 data: prevState.data,
16 };
17 }
18
19 const currentImageId = user.data.image?.id;
20
21 const image = formData.get("image") as File;
22
23 if (!image || image.size === 0) {
24 return {
25 success: false,
26 message: "No image provided",
27 strapiErrors: null,
28 zodErrors: { image: ["Image is required"] },
29 data: prevState.data,
30 };
31 }
32
33 const validatedFields = ProfileImageFormSchema.safeParse({ image });
34
35 if (!validatedFields.success) {
36 const flattenedErrors = z.flattenError(validatedFields.error);
37 console.log("Validation failed:", flattenedErrors.fieldErrors);
38 return {
39 success: false,
40 message: "Validation failed",
41 strapiErrors: null,
42 zodErrors: flattenedErrors.fieldErrors,
43 data: prevState.data,
44 };
45 }
46
47 console.log("Validation successful:", validatedFields.data);
48 console.log(currentImageId);
49 console.log(currentImageId);
50
51 // Delete previous image if exists
52 if (currentImageId) {
53 console.log(currentImageId);
54 try {
55 await services.file.fileDeleteService(currentImageId);
56 } catch (error) {
57 console.error("Failed to delete previous image:", error);
58 // Continue with upload even if delete fails
59 }
60 }
61
62 // Upload new image to media library
63 const fileUploadResponse = await services.file.fileUploadService(
64 validatedFields.data.image
65 );
66
67 if (!fileUploadResponse.success || !fileUploadResponse.data) {
68 return {
69 success: false,
70 message: "Failed to upload image",
71 strapiErrors: fileUploadResponse.error,
72 zodErrors: null,
73 data: prevState.data,
74 };
75 }
76
77 const uploadedImageId = fileUploadResponse.data[0].id;
78
79 // Update user profile with new image
80 const updateImageResponse = await services.profile.updateProfileImageService(
81 userId
82 );
83
84 if (!updateImageResponse.success) {
85 return {
86 success: false,
87 message: "Failed to update profile with new image",
88 strapiErrors: updateImageResponse.error,
89 zodErrors: null,
90 data: prevState.data,
91 };
92 }
93
94 console.log("#############");
95 console.log("Profile Image Updated Successfully");
96 console.log("#############");
97
98 return {
99 success: true,
100 message: "Profile image updated successfully",
101 strapiErrors: null,
102 zodErrors: null,
103 data: {
104 image: validatedFields.data.image,
105 },
106 };
107}
And don't forget to update your imports:
1import {
2 ProfileFormSchema,
3 ProfileImageFormSchema,
4 type ProfileFormState,
5 type ProfileImageFormState,
6} from "@/data/validation/profile";
You will notice that our code above relies on two new file action that we need to create fileUploadService
, fileDeleteService
, and updateProfileImageService
.
In our services
folder let's create a new file called file.ts
and add the following code:
1import { getStrapiURL } from "@/lib/utils";
2import type { TStrapiResponse } from "@/types";
3import { actions } from "@/data/actions";
4
5const baseUrl = getStrapiURL();
6
7type TImageFormat = {
8 name: string;
9 hash: string;
10 ext: string;
11 mime: string;
12 path: string | null;
13 width: number;
14 height: number;
15 size: number;
16 sizeInBytes: number;
17 url: string;
18};
19
20type TFileUploadResponse = {
21 id: number;
22 documentId: string;
23 name: string;
24 alternativeText: string | null;
25 caption: string | null;
26 width: number;
27 height: number;
28 formats: Record<string, TImageFormat> | null;
29 hash: string;
30 ext: string;
31 mime: string;
32 size: number;
33 url: string;
34 previewUrl: string | null;
35 provider: string;
36 provider_metadata: Record<string, unknown> | null;
37 createdAt: string;
38 updatedAt: string;
39 publishedAt: string;
40};
41
42export async function fileUploadService(
43 file: File
44): Promise<TStrapiResponse<TFileUploadResponse[]>> {
45 const authToken = await actions.auth.getAuthTokenAction();
46
47 if (!authToken) {
48 return {
49 success: false,
50 data: undefined,
51 error: {
52 status: 401,
53 name: "AuthError",
54 message: "No auth token found",
55 },
56 status: 401,
57 };
58 }
59
60 const url = new URL("/api/upload", baseUrl);
61 const formData = new FormData();
62 formData.append("files", file);
63
64 try {
65 const response = await fetch(url.href, {
66 method: "POST",
67 headers: {
68 Authorization: `Bearer ${authToken}`,
69 },
70 body: formData,
71 });
72
73 const data = await response.json();
74
75 if (!response.ok) {
76 console.error("File upload error:", data);
77 return {
78 success: false,
79 data: undefined,
80 error: {
81 status: response.status,
82 name: data?.error?.name ?? "UploadError",
83 message: data?.error?.message ?? "Failed to upload file",
84 },
85 status: response.status,
86 };
87 }
88
89 return {
90 success: true,
91 data: data,
92 error: undefined,
93 status: response.status,
94 };
95 } catch (error) {
96 console.error("File upload service error:", error);
97 return {
98 success: false,
99 data: undefined,
100 error: {
101 status: 500,
102 name: "NetworkError",
103 message: error instanceof Error ? error.message : "Upload failed",
104 },
105 status: 500,
106 };
107 }
108}
109
110export async function fileDeleteService(
111 fileId: number
112): Promise<TStrapiResponse<boolean>> {
113 const authToken = await actions.auth.getAuthTokenAction();
114
115 if (!authToken) {
116 return {
117 success: false,
118 data: undefined,
119 error: {
120 status: 401,
121 name: "AuthError",
122 message: "No auth token found",
123 },
124 status: 401,
125 };
126 }
127
128 const url = new URL(`/api/upload/files/${fileId}`, baseUrl);
129
130 try {
131 const response = await fetch(url.href, {
132 method: "DELETE",
133 headers: {
134 Authorization: `Bearer ${authToken}`,
135 },
136 });
137
138 if (!response.ok) {
139 const data = await response.json();
140 console.error("File delete error:", data);
141 return {
142 success: false,
143 data: undefined,
144 error: {
145 status: response.status,
146 name: data?.error?.name ?? "DeleteError",
147 message: data?.error?.message ?? "Failed to delete file",
148 },
149 status: response.status,
150 };
151 }
152
153 return {
154 success: true,
155 data: true,
156 error: undefined,
157 status: response.status,
158 };
159 } catch (error) {
160 console.error("File delete service error:", error);
161 return {
162 success: false,
163 data: undefined,
164 error: {
165 status: 500,
166 name: "NetworkError",
167 message: error instanceof Error ? error.message : "Delete failed",
168 },
169 status: 500,
170 };
171 }
172}
Let's add updateProfileImageService
in the services/profile.ts
file:
1export async function updateProfileImageService(
2 imageId: number
3): Promise<TStrapiResponse<TAuthUser>> {
4 const userId = (await services.auth.getUserMeService()).data?.id;
5 if (!userId) throw new Error("User Id is required");
6
7 const authToken = await actions.auth.getAuthTokenAction();
8 if (!authToken) throw new Error("You are not authorized");
9
10 const url = new URL("/api/users/" + userId, baseUrl);
11 const payload = { image: imageId };
12
13 const result = await api.put<TAuthUser, { image: number }>(
14 url.href,
15 payload,
16 { authToken }
17 );
18
19 console.dir(result, { depth: null });
20
21 return result;
22}
Don't forget to export both the newly created file services and the updateProfileImageService
that we just added in the index.ts
file:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6
7import { updateProfileService, updateProfileImageService } from "./profile";
8import { fileUploadService, fileDeleteService } from "./file";
9
10export const services = {
11 auth: {
12 registerUserService,
13 loginUserService,
14 getUserMeService,
15 },
16 profile: {
17 updateProfileService,
18 updateProfileImageService,
19 },
20 file: {
21 fileUploadService,
22 fileDeleteService,
23 },
24};
And finally make sure that you are exporting the updateProfileImageAction
in our actions/index.ts
file:
1import {
2 registerUserAction,
3 loginUserAction,
4 logoutUserAction,
5 getAuthTokenAction,
6} from "./auth";
7import { updateProfileAction, updateProfileImageAction } from "./profile";
8
9export const actions = {
10 auth: {
11 registerUserAction,
12 loginUserAction,
13 logoutUserAction,
14 getAuthTokenAction,
15 },
16 profile: {
17 updateProfileAction,
18 updateProfileImageAction,
19 },
20};
Nice, now we can uncomment the rest of the code in the app/(protected)/dashboard/account/page.tsx
file:
The completed code should look like the following.
1import { services } from "@/data/services";
2import { validateApiResponse } from "@/lib/error-handler";
3import { ProfileForm } from "@/components/forms/profile-form";
4import { ProfileImageForm } from "@/components/forms/profile-image-form";
5
6export default async function AccountRoute() {
7 const user = await services.auth.getUserMeService();
8 const userData = validateApiResponse(user, "user profile");
9 const userImage = userData?.image;
10
11 return (
12 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
13 <ProfileForm user={userData} className="col-span-3" />
14 <ProfileImageForm image={userImage} className="col-span-2" />
15 </div>
16 );
17}
Now, let's checkout out our account page and see if we see our image picker?
Now to hook everything up, wee need to add an image
field to our user collection type in Strapi Admin.
Navigate to Content Type Builder, click on the User collection type, and click on the Add Another Field to This Collection button.
Select the media
field.
Make sure to name it image
, select the Single media
option, and then navigate to the Advanced Settings
tab.
In the advanced settings tabs, configure allowed file types
only to include images. Once you've done this, click the Finish
button.
Now, add an image to your user.
Finally, before we move on, we need to update our get-user-me-loader.ts
file to include the image
field in the populate
query.
1url.search = qs.stringify({
2 populate: {
3 image: {
4 fields: ["url", "alternativeText"],
5 },
6 },
7});
Final code should look like the following.
1export async function getUserMeService(): Promise<TStrapiResponse<TAuthUser>> {
2 const authToken = await actions.auth.getAuthTokenAction();
3
4 if (!authToken)
5 return { success: false, data: undefined, error: undefined, status: 401 };
6
7 const url = new URL("/api/users/me", baseUrl);
8
9 url.search = qs.stringify({
10 populate: {
11 image: {
12 fields: ["url", "alternativeText"],
13 },
14 },
15 });
16
17 try {
18 const response = await fetch(url.href, {
19 method: "GET",
20 headers: {
21 "Content-Type": "application/json",
22 Authorization: `Bearer ${authToken}`,
23 },
24 });
25 const data = await response.json();
26 if (data.error)
27 return {
28 success: false,
29 data: undefined,
30 error: data.error,
31 status: response.status,
32 };
33 return {
34 success: true,
35 data: data,
36 error: undefined,
37 status: response.status,
38 };
39 } catch (error) {
40 console.log(error);
41 return {
42 success: false,
43 data: undefined,
44 error: {
45 status: 500,
46 name: "NetworkError",
47 message:
48 error instanceof Error
49 ? error.message
50 : "An unexpected error occurred",
51 details: {},
52 },
53 status: 500,
54 };
55 }
56}
Now refresh your frontend application; you should now see your newly added image via Strapi.
Before we can test our file upload functionality, we need to update Next.js configuration to allow larger file uploads. By default, server actions are limited to 1MB.
Update your next.config.ts
file to increase the limit:
1experimental: {
2 serverActions: {
3 bodySizeLimit: "5mb", // Increase from default 1mb to 5mb for image uploads
4 },
5 },
The full file should look like the following:
1import type { NextConfig } from "next";
2
3const nextConfig: NextConfig = {
4 /* config options here */
5 images: {
6 remotePatterns: [
7 {
8 protocol: "http",
9 hostname: "localhost",
10 port: "1337",
11 pathname: "/uploads/**/*",
12 },
13 ],
14 },
15 experimental: {
16 serverActions: {
17 bodySizeLimit: "5mb", // Increase from default 1mb to 5mb for image uploads
18 },
19 },
20};
21
22export default nextConfig;
Finally, we need to configure Strapi permissions to allow file uploads. In the Strapi admin panel, navigate to Users & Permissions plugin → Roles → Authenticated → Media Library and enable both upload
and destroy
permissions.
This allows authenticated users to upload new images and delete old ones when updating their profile picture.
Now you should be able to test the complete file upload functionality!
File Upload With Server Actions in Next.js Review
Throughout this tutorial, we implemented a comprehensive file upload system using Next.js Server Actions with Zod validation and robust error handling. Here's how our file upload implementation works:
File Validation with Zod
We use Zod to validate uploaded images in our validation/profile.ts
file:
1export const ProfileImageFormSchema = z.object({
2 image: z
3 .instanceof(File)
4 .refine((file) => file.size > 0, "Image is required")
5 .refine((file) => file.size <= 5000000, "Image must be less than 5MB")
6 .refine(
7 (file) => ["image/jpeg", "image/png", "image/webp"].includes(file.type),
8 "Image must be JPEG, PNG, or WebP format"
9 ),
10});
This schema validates three key aspects:
- File existence: Ensures a file was actually selected
- Size limits: Restricts uploads to 5MB maximum
- File types: Only allows JPEG, PNG, or WebP formats
Server Action File Upload Flow
Our updateProfileImageAction
handles the complete upload process:
- Authentication: Verifies user is logged in
- Validation: Uses Zod schema to validate the uploaded file
- Cleanup: Deletes existing profile image if present
- Upload: Sends file to Strapi's media library via
fileUploadService
- Update: Links new image to user profile via
updateProfileImageService
File Services Implementation
File Upload Service: Handles multipart form data uploads to Strapi's /api/upload
endpoint with proper authentication headers.
File Delete Service: Manages cleanup of old files through DELETE requests to prevent storage bloat.
Profile Update Service: Associates uploaded images with user profiles through Strapi's user API.
This implementation demonstrates how Next.js Server Actions can handle complex file operations while maintaining type safety and providing excellent error handling through our validation schema.
Before you go, let's improve our loader for our Accounts Page.
Better Loading with Skeleton
We currently have a general loading spinner in the root of our project, but did you know you can add additional loaders in your application that are route specific?
Let's do this in our account
folder by creating a new loading.tsx
file with the following. This will add a nice skeleton view while our account data loads.
1import { Skeleton } from "@/components/ui/skeleton";
2
3const styles = {
4 container: "grid grid-cols-1 lg:grid-cols-5 gap-4 p-4",
5 profileForm: "col-span-3 space-y-4",
6 profileImage: "col-span-2 space-y-4",
7 skeleton: "animate-pulse",
8 title: "h-8 w-1/3",
9 input: "h-10 w-full",
10 textarea: "h-24 w-full",
11 button: "h-10 w-24",
12 imageContainer: "h-48 w-full rounded-lg",
13};
14
15export default function AccountLoading() {
16 return (
17 <div className={styles.container}>
18 {/* Profile Form Skeleton */}
19 <div className={styles.profileForm}>
20 <Skeleton className={`${styles.skeleton} ${styles.title}`} />
21 <Skeleton className={`${styles.skeleton} ${styles.input}`} />
22 <Skeleton className={`${styles.skeleton} ${styles.input}`} />
23 <Skeleton className={`${styles.skeleton} ${styles.input}`} />
24 <Skeleton className={`${styles.skeleton} ${styles.input}`} />
25 <Skeleton className={`${styles.skeleton} ${styles.textarea}`} />
26 <Skeleton className={`${styles.skeleton} ${styles.button}`} />
27 </div>
28
29 {/* Profile Image Skeleton */}
30 <div className={styles.profileImage}>
31 <Skeleton className={`${styles.skeleton} ${styles.title}`} />
32 <Skeleton className={`${styles.skeleton} ${styles.imageContainer}`} />
33 <Skeleton className={`${styles.skeleton} ${styles.button}`} />
34 </div>
35 </div>
36 );
37}
We are using ShadCn UI Skeleton component. You can learn more about it here.
So we need to install it since we are using it in our code above.
npx shadcn@latest add skeleton
Nice! Now reload your account page and the loader should be much nicer.
Conclusion
Excellent. We completed our initial Dashboard layout with an Account section where the user can update their first name
, last name
, bio
, and image
.
We covered how to handle file uploads using NextJs server actions. By this point, you should be starting to feel more comfortable working with forms, file upload and server actions in Next.js.
In the next post, we will start working on our main feature, which will allow us to summarize our YouTube videos.
See you in the next one.
Also, if you made it this far, thank you. I really appreciate your support. I did my best to do diligence, but if you find errors, share them in the comments below.
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.
If you have a suggestion or find a mistake in the post, please open an issue on the GitHub repository.
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