In the previous tutorial, we finished our Home Page, so we will build out our Sign In and Sign Up Pages and hook up the logic to allow us to sign in and sign up.
Welcome to the next part of our React tutorial with Next.js. In the last post, we finished our Signup & Signin Page with authentication using HTTPOnly cookies and saw how to protect our routes via Next.js middleware.
In this section, we will be working on completing our Dashboard and Profile Page, where we will look at uploading files using NextJs server actions. At the end of the tutorial, you should be able to perform any file upload, handling of forms and server actions in Next.Js 14.
Currently, our Dashboard Page looks like the following. Let's create a layout.tsx
page to give our page shared styling.
Navigate to src/app/dashboard
, create a file called layout.tsx,
and add the following code.
1import Link from "next/link";
2
3export default function DashboardLayout({
4 children,
5}: {
6 readonly children: React.ReactNode;
7}) {
8 return (
9 <div className="h-screen grid grid-cols-[240px_1fr]">
10 <nav className="border-r bg-gray-100/40 dark:bg-gray-800/40">
11 <div className="flex h-full max-h-screen flex-col gap-2">
12 <div className="flex h-[60px] items-center border-b px-6">
13 <Link
14 className="flex items-center gap-2 font-semibold"
15 href="/dashboard"
16 >
17 <LayoutDashboardIcon className="h-6 w-6" />
18 <span className="">Dashboard</span>
19 </Link>
20 </div>
21 <div className="flex-1 overflow-auto py-2">
22 <nav className="grid items-start px-4 text-sm font-medium">
23 <Link
24 className="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"
25 href="/dashboard/summaries"
26 >
27 <ViewIcon className="h-4 w-4" />
28 Summaries
29 </Link>
30
31 <Link
32 className="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"
33 href="/dashboard/account"
34 >
35 <UsersIcon className="h-4 w-4" />
36 Account
37 </Link>
38 </nav>
39 </div>
40 </div>
41 </nav>
42 <main className="flex flex-col overflow-scroll">{children}</main>
43 </div>
44 );
45}
46
47function LayoutDashboardIcon(props: any) {
48 return (
49 <svg
50 {...props}
51 xmlns="http://www.w3.org/2000/svg"
52 width="24"
53 height="24"
54 viewBox="0 0 24 24"
55 fill="none"
56 stroke="currentColor"
57 strokeWidth="2"
58 strokeLinecap="round"
59 strokeLinejoin="round"
60 >
61 <rect width="7" height="9" x="3" y="3" rx="1" />
62 <rect width="7" height="5" x="14" y="3" rx="1" />
63 <rect width="7" height="9" x="14" y="12" rx="1" />
64 <rect width="7" height="5" x="3" y="16" rx="1" />
65 </svg>
66 );
67}
68
69function PieChartIcon(props: any) {
70 return (
71 <svg
72 {...props}
73 xmlns="http://www.w3.org/2000/svg"
74 width="24"
75 height="24"
76 viewBox="0 0 24 24"
77 fill="none"
78 stroke="currentColor"
79 strokeWidth="2"
80 strokeLinecap="round"
81 strokeLinejoin="round"
82 >
83 <path d="M21.21 15.89A10 10 0 1 1 8 2.83" />
84 <path d="M22 12A10 10 0 0 0 12 2v10z" />
85 </svg>
86 );
87}
88
89function UsersIcon(props: any) {
90 return (
91 <svg
92 {...props}
93 xmlns="http://www.w3.org/2000/svg"
94 width="24"
95 height="24"
96 viewBox="0 0 24 24"
97 fill="none"
98 stroke="currentColor"
99 strokeWidth="2"
100 strokeLinecap="round"
101 strokeLinejoin="round"
102 >
103 <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
104 <circle cx="9" cy="7" r="4" />
105 <path d="M22 21v-2a4 4 0 0 0-3-3.87" />
106 <path d="M16 3.13a4 4 0 0 1 0 7.75" />
107 </svg>
108 );
109}
110
111function ViewIcon(props: any) {
112 return (
113 <svg
114 {...props}
115 xmlns="http://www.w3.org/2000/svg"
116 width="24"
117 height="24"
118 viewBox="0 0 24 24"
119 fill="none"
120 stroke="currentColor"
121 strokeWidth="2"
122 strokeLinecap="round"
123 strokeLinejoin="round"
124 >
125 <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" />
126 <path d="M12 13a1 1 0 1 0 0-2 1 1 0 0 0 0 2z" />
127 <path d="M21 17v2a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-2" />
128 <path d="M21 7V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2" />
129 </svg>
130 );
131}
Your updated UI should look like the following.
Currently, our Top Header does not show the user who is logged in when we are logged in. Let's go ahead and update it.
Navigate to src/components/custom/header.tsx
and make the following changes.
First, let's import our getUserMeLoader
, a function we created in the previous video to retrieve our users' data if they are logged in.
1import { getUserMeLoader } from "@/data/services/get-user-me-loader";
Next, let's call it inside our Header component with the following.
1const user = await getUserMeLoader();
2console.log(user);
If you are logged in, you should see your user data in the console.
1{
2 ok: true,
3 data: {
4 id: 3,
5 username: 'testuser',
6 email: 'testuser@email.com',
7 provider: 'local',
8 confirmed: true,
9 blocked: false,
10 createdAt: '2024-03-23T20:32:32.978Z',
11 updatedAt: '2024-03-23T20:32:32.978Z'
12 },
13 error: null
14}
We can use the ok
key to conditionally render our Sign Up
button or the user's Name and Logout Button.
Before we can do that, let's import our Logout Button first with the following.
1import { LogoutButton } from "./logout-button";
Now, let's create a simple component showing the logout button and the user name. The code is in the following snippet.
1interface AuthUserProps {
2 username: string;
3 email: string;
4}
5
6export function LoggedInUser({
7 userData,
8}: {
9 readonly userData: AuthUserProps;
10}) {
11 return (
12 <div className="flex gap-2">
13 <Link
14 href="/dashboard/account"
15 className="font-semibold hover:text-primary"
16 >
17 {userData.username}
18 </Link>
19 <LogoutButton />
20 </div>
21 );
22}
Now, let's update the following code.
1<div className="flex items-center gap-4">
2 <Link href={ctaButton.url}>
3 <Button>{ctaButton.text}</Button>
4 </Link>
5</div>
And replace them with the new changes.
1<div className="flex items-center gap-4">
2 {user.ok ? (
3 <LoggedInUser userData={user.data} />
4 ) : (
5 <Link href={ctaButton.url}>
6 <Button>{ctaButton.text}</Button>
7 </Link>
8 )}
9</div>
Let's also add the following to check if there is no header data, and display this message.
1if (!data) return <div>No Header Data</div>;
We will keep this basic check but feel free to add more robust checks if you like.
The completed code in our header.tsx
file should look like the following.
1import Link from "next/link";
2
3import { getUserMeLoader } from "@/data/services/get-user-me-loader";
4
5import { Logo } from "@/components/custom/logo";
6import { Button } from "@/components/ui/button";
7import { LogoutButton } from "./logout-button";
8
9interface HeaderProps {
10 data: {
11 logoText: {
12 id: number;
13 text: string;
14 url: string;
15 };
16 ctaButton: {
17 id: number;
18 text: string;
19 url: string;
20 };
21 };
22}
23
24interface AuthUserProps {
25 username: string;
26 email: string;
27}
28
29export function LoggedInUser({
30 userData,
31}: {
32 readonly userData: AuthUserProps;
33}) {
34 return (
35 <div className="flex gap-2">
36 <Link
37 href="/dashboard/account"
38 className="font-semibold hover:text-primary"
39 >
40 {userData.username}
41 </Link>
42 <LogoutButton />
43 </div>
44 );
45}
46
47export async function Header({ data }: Readonly<HeaderProps>) {
48 const { logoText, ctaButton } = data;
49 const user = await getUserMeLoader();
50
51 return (
52 <div className="flex items-center justify-between px-4 py-3 bg-white shadow-md dark:bg-gray-800">
53 <Logo text={logoText.text} />
54 <div className="flex items-center gap-4">
55 {user.ok ? (
56 <LoggedInUser userData={user.data} />
57 ) : (
58 <Link href={ctaButton.url}>
59 <Button>{ctaButton.text}</Button>
60 </Link>
61 )}
62 </div>
63 </div>
64 );
65}
Nice. Now, when you are logged in, you should see the username and Logout Buttons.
Let's make another quick change in our hero-section.tsx
file, which is in the src/components/custom
folder.
The cool part about React Server Components is that they can be responsible for their own data. Let's update it so that if the user is Logged In, they will see the button to take them to the Dashboard.
Let's make the following changes.
1import Link from "next/link";
2import { getUserMeLoader } from "@/data/services/get-user-me-loader";
3import { StrapiImage } from "@/components/custom/strapi-image";
4
5interface Image {
6 id: number;
7 documentId: string;
8 url: string;
9 alternativeText: string | null;
10}
11
12interface Link {
13 id: number;
14 url: string;
15 text: string;
16}
17
18interface HeroSectionProps {
19 id: number;
20 documentId: string;
21 __component: string;
22 heading: string;
23 subHeading: string;
24 image: Image;
25 link: Link;
26}
27
28export async function HeroSection({
29 data,
30}: {
31 readonly data: HeroSectionProps;
32}) {
33 const user = await getUserMeLoader();
34 const userLoggedIn = user?.ok;
35
36 const { heading, subHeading, image, link } = data;
37 const linkUrl = userLoggedIn ? "/dashboard" : link.url;
38
39 return (
40 <header className="relative h-[600px] overflow-hidden">
41 <StrapiImage
42 alt={image.alternativeText ?? "no alternative text"}
43 className="absolute inset-0 object-cover w-full h-full aspect/16:9"
44 src={image.url}
45 height={1080}
46 width={1920}
47 />
48 <div className="relative z-10 flex flex-col items-center justify-center h-full text-center text-white bg-black bg-opacity-40">
49 <h1 className="text-4xl font-bold md:text-5xl lg:text-6xl">
50 {heading}
51 </h1>
52 <p className="mt-4 text-lg md:text-xl lg:text-2xl">{subHeading}</p>
53 <Link
54 className="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"
55 href={linkUrl}
56 >
57 {userLoggedIn ? "Dashboard" : link.text}
58 </Link>
59 </div>
60 </header>
61 );
62}
Now, our UI in the Hero Section should look like the following if the user is logged in.
Now, let's work on our Account Page.
Let's start by navigating to our dashboard
folder and creating an account
folder with a page.tsx
file.
We will add the following code.
1import { getUserMeLoader } from "@/data/services/get-user-me-loader";
2// import { ProfileForm } from "@/components/forms/profile-form";
3// import { ProfileImageForm } from "@/components/forms/profile-image-form";
4
5export default async function AccountRoute() {
6 const user = await getUserMeLoader();
7 const userData = user.data;
8 const userImage = userData?.image;
9
10 return (
11 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
12 Account Page
13 {/* <ProfileForm data={userData} className="col-span-3" /> */}
14 {/* <ProfileImageForm data={userImage} className="col-span-2" /> */}
15 </div>
16 );
17}
I commented out the components that we still need to create to get our app to render. Let's make our ProfileForm and ProfileImageForm components.
Let's navigate to src/components/forms
and create a profile-form.tsx
file.
Let's paste in the following code.
1"use client";
2import React from "react";
3import { cn } from "@/lib/utils";
4
5import { SubmitButton } from "@/components/custom/submit-button";
6import { Input } from "@/components/ui/input";
7import { Textarea } from "@/components/ui/textarea";
8
9interface ProfileFormProps {
10 id: string;
11 username: string;
12 email: string;
13 firstName: string;
14 lastName: string;
15 bio: string;
16 credits: number;
17}
18
19function CountBox({ text }: { readonly text: number }) {
20 const style = "font-bold text-md mx-1";
21 const color = text > 0 ? "text-primary" : "text-red-500";
22 return (
23 <div className="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">
24 You have<span className={cn(style, color)}>{text}</span>credit(s)
25 </div>
26 );
27}
28
29export function ProfileForm({
30 data,
31 className,
32}: {
33 readonly data: ProfileFormProps;
34 readonly className?: string;
35}) {
36 return (
37 <form className={cn("space-y-4", className)}>
38 <div className="space-y-4 grid ">
39 <div className="grid grid-cols-3 gap-4">
40 <Input
41 id="username"
42 name="username"
43 placeholder="Username"
44 defaultValue={data?.username || ""}
45 disabled
46 />
47 <Input
48 id="email"
49 name="email"
50 placeholder="Email"
51 defaultValue={data?.email || ""}
52 disabled
53 />
54 <CountBox text={data?.credits} />
55 </div>
56
57 <div className="grid grid-cols-2 gap-4">
58 <Input
59 id="firstName"
60 name="firstName"
61 placeholder="First Name"
62 defaultValue={data?.firstName || ""}
63 />
64 <Input
65 id="lastName"
66 name="lastName"
67 placeholder="Last Name"
68 defaultValue={data?.lastName || ""}
69 />
70 </div>
71 <Textarea
72 id="bio"
73 name="bio"
74 placeholder="Write your bio here..."
75 className="resize-none border rounded-md w-full h-[224px] p-2"
76 defaultValue={data?.bio || ""}
77 required
78 />
79 </div>
80 <div className="flex justify-end">
81 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
82 </div>
83 </form>
84 );
85}
Since we use a new Shadcn UI component, Textarea,
let's install it using the following:
npx shadcn@latest add textarea
Let's uncomment our ProfileForm in our dashboard/account/page.tsx
file.
1import { getUserMeLoader } from "@/data/services/get-user-me-loader";
2import { ProfileForm } from "@/components/forms/profile-form";
3// import { ProfileImageForm } from "@/components/forms/profile-image-form";
4
5export default async function AccountRoute() {
6 const user = await getUserMeLoader();
7 const userData = user.data;
8 const userImage = userData?.image;
9
10 return (
11 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
12 <ProfileForm data={userData} className="col-span-3" />
13 {/* <ProfileImageForm data={userImage} className="col-span-2" /> */}
14 </div>
15 );
16}
Restart the app and your Next.js frontend, and you should see the following.
You should notice two things. One, we are not getting our users' First Name, Last Name, Bio, or the number of credits they have left.
Second, we are not able to submit the form because we have not implemented the form logic via server action yet. We will do that next. But first, let's update our user schema in Strapi.
Inside our Strapi Admin area, navigate to the content-builder
and choose the user collection type.
Let's add the following fields.
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 |
We will manually add the credits for new users when they sign in, but their default starting credits should be 0
.
Once you are done, you should have the following new fields.
Now, let's manually update our users' information so we can check whether we are getting it in our front end.
Navigating to your Account
page on your front end should see the following output.
Let's move on to the form update using server action.
First, let's create our updateProfileAction
responsible for handling our form submission.
Navigate to src/data/actions
, create a new file called profile-actions.ts
and paste in the following code.
1"use server";
2import qs from "qs";
3
4export async function updateProfileAction(
5 userId: string,
6 prevState: any,
7 formData: FormData
8) {
9 const rawFormData = Object.fromEntries(formData);
10
11 const query = qs.stringify({
12 populate: "*",
13 });
14
15 const payload = {
16 firstName: rawFormData.firstName,
17 lastName: rawFormData.lastName,
18 bio: rawFormData.bio,
19 };
20
21 console.log("updateProfileAction", userId);
22 console.log("############################");
23 console.log(payload);
24 console.log("############################");
25
26 return {
27 ...prevState,
28 message: "Profile Updated",
29 data: payload,
30 strapiErrors: null,
31 };
32}
We have created actions before, so there is not much new here except one small addition. Notice that we can access userId,
which we are getting as one of the arguments.
Let's implement this action in our ProfileForm
and see how we pass the userId
to our action.
Navigate to your profile-form.tsx
file and make the following changes.
First, let's import our action with the following.
1import { useActionState} from "react";
2import { updateProfileAction } from "@/data/actions/profile-actions";
Next, let's create the initial state for our useActionState
.
1const INITIAL_STATE = {
2 data: null,
3 strapiErrors: null,
4 message: null,
5};
I will not focus on form validation with Zod since we already covered this in previous sections. It can be a great extra challenge for you to explore independently and to see how useful it is for data validation in JavaScript/TypeScript applications and frameworks such as Next.JS 14, VueJs, Remix, etc.
But we will import our StrapiErrors component and handle those.
1import { StrapiErrors } from "@/components/custom/strapi-errors";
Before using the useActionState
as we did in previous sections, let's look at how we can bind additional data that we would like to pass to our server actions.
Add the following line of code.
1const updateProfileWithId = updateProfileAction.bind(null, data.id);
We can use the bind
method to add new data that we can access inside our server action.
This is how we are setting our userId
so that we can access it from our updateProfileAction
server action.
You can read more about it in the Next.js documentation here.
Finally, let's use our useActionState
hook to access the data returned from our server actions.
1const [formState, formAction] = useActionState(
2 updateProfileWithId,
3 INITIAL_STATE
4);
Let's update our form
tag with the following.
1 <form
2 className={cn("space-y-4", className)}
3 action={formAction}
4 >
And remember to add our StrapiErrors component.
1<div className="flex justify-end">
2 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
3</div>
4<StrapiErrors error={formState?.strapiErrors} />
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 { useActionState} from "react";
6import { updateProfileAction } from "@/data/actions/profile-actions";
7
8import { SubmitButton } from "@/components/custom/submit-button";
9import { Input } from "@/components/ui/input";
10import { Textarea } from "@/components/ui/textarea";
11import { StrapiErrors } from "@/components/custom/strapi-errors";
12
13const INITIAL_STATE = {
14 data: null,
15 strapiErrors: null,
16 message: null,
17};
18
19interface ProfileFormProps {
20 id: string;
21 username: string;
22 email: string;
23 firstName: string;
24 lastName: string;
25 bio: string;
26 credits: number;
27}
28
29function CountBox({ text }: { readonly text: number }) {
30 const style = "font-bold text-md mx-1";
31 const color = text > 0 ? "text-primary" : "text-red-500";
32 return (
33 <div className="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">
34 You have<span className={cn(style, color)}>{text}</span>credit(s)
35 </div>
36 );
37}
38
39export function ProfileForm({
40 data,
41 className,
42}: {
43 readonly data: ProfileFormProps;
44 readonly className?: string;
45}) {
46 const updateProfileWithId = updateProfileAction.bind(null, data.id);
47
48 const [formState, formAction] = useActionState(
49 updateProfileWithId,
50 INITIAL_STATE
51 );
52
53 return (
54 <form className={cn("space-y-4", className)} action={formAction}>
55 <div className="space-y-4 grid ">
56 <div className="grid grid-cols-3 gap-4">
57 <Input
58 id="username"
59 name="username"
60 placeholder="Username"
61 defaultValue={data?.username || ""}
62 disabled
63 />
64 <Input
65 id="email"
66 name="email"
67 placeholder="Email"
68 defaultValue={data?.email || ""}
69 disabled
70 />
71 <CountBox text={data?.credits} />
72 </div>
73
74 <div className="grid grid-cols-2 gap-4">
75 <Input
76 id="firstName"
77 name="firstName"
78 placeholder="First Name"
79 defaultValue={data?.firstName || ""}
80 />
81 <Input
82 id="lastName"
83 name="lastName"
84 placeholder="Last Name"
85 defaultValue={data?.lastName || ""}
86 />
87 </div>
88 <Textarea
89 id="bio"
90 name="bio"
91 placeholder="Write your bio here..."
92 className="resize-none border rounded-md w-full h-[224px] p-2"
93 defaultValue={data?.bio || ""}
94 required
95 />
96 </div>
97 <div className="flex justify-end">
98 <SubmitButton text="Update Profile" loadingText="Saving Profile" />
99 </div>
100 <StrapiErrors error={formState?.strapiErrors} />
101 </form>
102 );
103}
Let's test it and see if we can console our changes before making the API call to Strapi.
If you check your terminal, you will see the following console message.
1updateProfileAction 3
2############################
3{
4 firstName: 'Paul',
5 lastName: 'Brats',
6 bio: 'I made this update'
7}
8############################
Notice we are getting our userId and the data we want to update.
Now, let's go ahead and implement the logic that will send this data to Strapi.
But first, let's navigate to src/data/services
, create a new service called mutate-data.ts
, and import the following code.
1import { getAuthToken } from "./get-token";
2import { getStrapiURL } from "@/lib/utils";
3
4export async function mutateData(method: string, path: string, payload?: any) {
5 const baseUrl = getStrapiURL();
6 const authToken = await getAuthToken();
7 const url = new URL(path, baseUrl);
8
9 if (!authToken) throw new Error("No auth token found");
10
11 try {
12 const response = await fetch(url, {
13 method: method,
14 headers: {
15 "Content-Type": "application/json",
16 Authorization: `Bearer ${authToken}`,
17 },
18 body: JSON.stringify({ ...payload }),
19 });
20
21 if (method === 'DELETE') {
22 return response.ok;
23 }
24
25 const data = await response?.json();
26 return data;
27 } catch (error) {
28 console.log("error", error);
29 throw error;
30 }
31}
Here, we are just using fetch to submit our data, but to make it more flexible and reusable, we are passing path
and payload
as arguments.
Let's use it on our profile-actions.ts
file.
Let's make the following update.
1"use server";
2import qs from "qs";
3import { mutateData } from "@/data/services/mutate-data";
4import { revalidatePath } from "next/cache";
5
6export async function updateProfileAction(
7 userId: string,
8 prevState: any,
9 formData: FormData
10) {
11 const rawFormData = Object.fromEntries(formData);
12
13 const query = qs.stringify({
14 populate: "*",
15 });
16
17 const payload = {
18 firstName: rawFormData.firstName,
19 lastName: rawFormData.lastName,
20 bio: rawFormData.bio,
21 };
22
23 const responseData = await mutateData(
24 "PUT",
25 `/api/users/${userId}?${query}`,
26 payload
27 );
28
29 if (!responseData) {
30 return {
31 ...prevState,
32 strapiErrors: null,
33 message: "Ops! Something went wrong. Please try again.",
34 };
35 }
36
37 if (responseData.error) {
38 return {
39 ...prevState,
40 strapiErrors: responseData.error,
41 message: "Failed to Update Profile.",
42 };
43 }
44
45 revalidatePath("/dashboard/account");
46
47 return {
48 ...prevState,
49 message: "Profile Updated",
50 data: responseData,
51 strapiErrors: null,
52 };
53
54}
We will be using revalidatePath
to clear the cache and fetch the latest data from Strapi.
Read more about revalidatePath
here.
Now, try to update your form, and you will get the forbidden
message.
In order for this to work, we need to grant permission to make the changes in Strapi's Admin.
note: One thing to remember is that you should take an additional step to protect your User route by creating an additional policy
that will only allow you to update your user data.
We will cover this as a supplement after we complete this series.
Let's try to update our profile and see if it works.
Now that we can update our profile. Let's take a look at how we can upload files in Next.js.
We will now focus on handling file upload in Next.js with Server Actions. But before we can do that, let's create an ImagePicker component.
Navigate to src/components/custom
, create a file called image-picker.tsx,
, and paste 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}
15
16function generateDataUrl(file: File, callback: (imageUrl: string) => void) {
17 const reader = new FileReader();
18 reader.onload = () => callback(reader.result as string);
19 reader.readAsDataURL(file);
20}
21
22function ImagePreview({ dataUrl }: { readonly dataUrl: string }) {
23 return (
24 <StrapiImage
25 src={dataUrl}
26 alt="preview"
27 height={200}
28 width={200}
29 className="rounded-lg w-full object-cover"
30 />
31 );
32}
33
34function ImageCard({
35 dataUrl,
36 fileInput,
37}: {
38 readonly dataUrl: string;
39 readonly fileInput: React.RefObject<HTMLInputElement>;
40}) {
41 const imagePreview = dataUrl ? (
42 <ImagePreview dataUrl={dataUrl} />
43 ) : (
44 <p>No image selected</p>
45 );
46
47 return (
48 <div className="w-full relative">
49 <div className=" flex items-center space-x-4 rounded-md border p-4">
50 {imagePreview}
51 </div>
52 <button
53 onClick={() => fileInput.current?.click()}
54 className="w-full absolute inset-0"
55 type="button"
56 ></button>
57 </div>
58 );
59}
60
61export default function ImagePicker({
62 id,
63 name,
64 label,
65 defaultValue,
66}: Readonly<ImagePickerProps>) {
67 const fileInput = useRef<HTMLInputElement>(null);
68 const [dataUrl, setDataUrl] = useState<string | null>(defaultValue ?? null);
69
70 const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
71 const file = e.target.files?.[0];
72 if (file) generateDataUrl(file, setDataUrl);
73 };
74
75 return (
76 <React.Fragment>
77 <div className="hidden">
78 <Label htmlFor={name}>{label}</Label>
79 <Input
80 type="file"
81 id={id}
82 name={name}
83 onChange={handleFileChange}
84 ref={fileInput}
85 accept="image/*"
86 />
87 </div>
88 <ImageCard dataUrl={dataUrl ?? ""} fileInput={fileInput} />
89 </React.Fragment>
90 );
91}
This component lets the user select an image in the form.
Now, let's create our ProfileImageForm to utilize this component.
Navigate to src/components/forms
, create a file called profile-image-form.tsx
, and paste it into the following code.
1"use client";
2import React from "react";
3// import { useActionState} from "react";
4
5import { cn } from "@/lib/utils";
6
7// import { uploadProfileImageAction } from "@/data/actions/profile-actions";
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 ProfileImageFormProps {
15 id: string;
16 url: string;
17 alternativeText: string;
18}
19
20const initialState = {
21 message: null,
22 data: null,
23 strapiErrors: null,
24 zodErrors: null,
25};
26
27export function ProfileImageForm({
28 data,
29 className,
30}: {
31 data: Readonly<ProfileImageFormProps>;
32 className?: string;
33}) {
34 // const uploadProfileImageWithIdAction = uploadProfileImageAction.bind(
35 // null,
36 // data?.id
37 // );
38
39 // const [formState, formAction] = useActionState(
40 // uploadProfileImageWithIdAction,
41 // initialState
42 // );
43
44 return (
45 <form className={cn("space-y-4", className)}>
46 <div className="">
47 <ImagePicker
48 id="image"
49 name="image"
50 label="Profile Image"
51 defaultValue={data?.url || ""}
52 />
53 {/* <ZodErrors error={formState?.zodErrors?.image} />
54 <StrapiErrors error={formState?.strapiErrors} /> */}
55 </div>
56 <div className="flex justify-end">
57 <SubmitButton text="Update Image" loadingText="Saving Image" />
58 </div>
59 </form>
60 );
61}
We have couple of the items commented out, but we will come back to them in just a moment.
But first, let's uncomment our ProfileImageForm
in the app/dashboard/account/page.tsx
.
The completed code should look like the following.
1import { getUserMeLoader } from "@/data/services/get-user-me-loader";
2import { ProfileForm } from "@/components/forms/profile-form";
3import { ProfileImageForm } from "@/components/forms/profile-image-form";
4
5export default async function AccountRoute() {
6 const user = await getUserMeLoader();
7 const userData = user.data;
8 const userImage = userData?.image;
9
10 return (
11 <div className="grid grid-cols-1 lg:grid-cols-5 gap-4 p-4">
12 <ProfileForm data={userData} className="col-span-3" />
13 <ProfileImageForm data={userImage} className="col-span-2" />
14 </div>
15 );
16}
Now, let's checkout out our account page and see if we see our image picker?
Nice. Now let's add the 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.
1populate: {
2 image: {
3 fields: ["url", "alternativeText"],
4 },
5},
Final code should look like the following.
1import qs from "qs";
2import { getAuthToken } from "./get-token";
3import { getStrapiURL } from "@/lib/utils";
4
5export async function getUserMeLoader() {
6 const baseUrl = getStrapiURL();
7
8 const url = new URL("/api/users/me", baseUrl);
9
10 url.search = qs.stringify({
11 populate: {
12 image: {
13 fields: ["url", "alternativeText"],
14 },
15 },
16 });
17
18 const authToken = await getAuthToken();
19 if (!authToken) return { ok: false, data: null, error: null };
20
21 try {
22 const response = await fetch(url.href, {
23 method: "GET",
24 headers: {
25 "Content-Type": "application/json",
26 Authorization: `Bearer ${authToken}`,
27 },
28 });
29 const data = await response.json();
30 if (data.error) return { ok: false, data: null, error: data.error };
31 return { ok: true, data: data, error: null };
32 } catch (error) {
33 console.log(error);
34 return { ok: false, data: null, error: error };
35 }
36}
Now refresh your frontend application; you should now see your newly added user image.
Let's now create our server action to handle file upload.
Let's navigate to profile-actions.ts
and update the file with the following code.
1"use server";
2import { z } from "zod";
3import qs from "qs";
4import { revalidatePath } from "next/cache";
5
6import { getUserMeLoader } from "@/data/services/get-user-me-loader";
7import { mutateData } from "@/data/services/mutate-data";
8
9import {
10 fileDeleteService,
11 fileUploadService,
12} from "@/data/services/file-service";
13
14export async function updateProfileAction(
15 userId: string,
16 prevState: any,
17 formData: FormData
18) {
19 const rawFormData = Object.fromEntries(formData);
20
21 const query = qs.stringify({
22 populate: "*",
23 });
24
25 const payload = {
26 firstName: rawFormData.firstName,
27 lastName: rawFormData.lastName,
28 bio: rawFormData.bio,
29 };
30
31 const responseData = await mutateData(
32 "PUT",
33 `/api/users/${userId}?${query}`,
34 payload
35 );
36
37 if (!responseData) {
38 return {
39 ...prevState,
40 strapiErrors: null,
41 message: "Ops! Something went wrong. Please try again.",
42 };
43 }
44
45 if (responseData.error) {
46 return {
47 ...prevState,
48 strapiErrors: responseData.error,
49 message: "Failed to Update Profile.",
50 };
51 }
52
53 revalidatePath("/dashboard/account");
54
55 return {
56 ...prevState,
57 message: "Profile Updated",
58 data: responseData,
59 strapiErrors: null,
60 };
61}
62
63const MAX_FILE_SIZE = 5000000;
64
65const ACCEPTED_IMAGE_TYPES = [
66 "image/jpeg",
67 "image/jpg",
68 "image/png",
69 "image/webp",
70];
71
72// VALIDATE IMAGE WITH ZOD
73const imageSchema = z.object({
74 image: z
75 .any()
76 .refine((file) => {
77 if (file.size === 0 || file.name === undefined) return false;
78 else return true;
79 }, "Please update or add new image.")
80
81 .refine(
82 (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type),
83 ".jpg, .jpeg, .png and .webp files are accepted."
84 )
85 .refine((file) => file.size <= MAX_FILE_SIZE, `Max file size is 5MB.`),
86});
87
88export async function uploadProfileImageAction(
89 imageId: string,
90 prevState: any,
91 formData: FormData
92) {
93 // GET THE LOGGED IN USER
94 const user = await getUserMeLoader();
95 if (!user.ok)
96 throw new Error("You are not authorized to perform this action.");
97
98 const userId = user.data.id;
99
100 // CONVERT FORM DATA TO OBJECT
101 const data = Object.fromEntries(formData);
102
103 // VALIDATE THE IMAGE
104 const validatedFields = imageSchema.safeParse({
105 image: data.image,
106 });
107
108 if (!validatedFields.success) {
109 return {
110 ...prevState,
111 zodErrors: validatedFields.error.flatten().fieldErrors,
112 strapiErrors: null,
113 data: null,
114 message: "Invalid Image",
115 };
116 }
117
118 // DELETE PREVIOUS IMAGE IF EXISTS
119 if (imageId) {
120 try {
121 await fileDeleteService(imageId);
122 } catch (error) {
123 return {
124 ...prevState,
125 strapiErrors: null,
126 zodErrors: null,
127 message: "Failed to Delete Previous Image.",
128 };
129 }
130 }
131
132 // UPLOAD NEW IMAGE TO MEDIA LIBRARY
133 const fileUploadResponse = await fileUploadService(data.image);
134
135 if (!fileUploadResponse) {
136 return {
137 ...prevState,
138 strapiErrors: null,
139 zodErrors: null,
140 message: "Ops! Something went wrong. Please try again.",
141 };
142 }
143
144 if (fileUploadResponse.error) {
145 return {
146 ...prevState,
147 strapiErrors: fileUploadResponse.error,
148 zodErrors: null,
149 message: "Failed to Upload File.",
150 };
151 }
152 const updatedImageId = fileUploadResponse[0].id;
153 const payload = { image: updatedImageId };
154
155 // UPDATE USER PROFILE WITH NEW IMAGE
156 const updateImageResponse = await mutateData(
157 "PUT",
158 `/api/users/${userId}`,
159 payload
160 );
161
162 revalidatePath("/dashboard/account");
163
164 return {
165 ...prevState,
166 data: updateImageResponse,
167 zodErrors: null,
168 strapiErrors: null,
169 message: "Image Uploaded",
170 };
171}
The above server action handles the following steps.
In essence, this code is about two main actions on a user's profile in an application: updating personal details and changing the profile picture.
It ensures that the data is valid via Zod and communicates with the server to store these changes, providing feedback to the user based on whether these actions were successful.
To check if the image is valid, we use the refine
method in Zod, which allows us to create custom validation logic.
Let's briefly review the use of refine
in the code below.
1const imageSchema = z.object({
2 image: z
3 .any()
4 .refine((file) => {
5 if (file.size === 0 || file.name === undefined) return false;
6 else return true;
7 }, "Please update or add new image.")
8
9 .refine(
10 (file) => ACCEPTED_IMAGE_TYPES.includes(file?.type),
11 ".jpg, .jpeg, .png and .webp files are accepted."
12 )
13 .refine((file) => file.size <= MAX_FILE_SIZE, `Max file size is 5MB.`),
14});
Why Use refine Flexibility and Precision: Refines allow for custom validations beyond basic type checks, which means you can implement complex, tailored criteria for data validity.
User Feedback: By specifying an error message with each refine, you provide clear, actionable feedback, improving the user experience by guiding them through correcting their input.
Composition: Multiple refine validations can be chained together, allowing for a comprehensive and readable sequence of checks.
You can learn more about using refine
here.
Finally, let's create our two services, fileDeleteService and fileUploadService. They will be responsible for deleting an existing image and uploading a new one.
Navigate to src/data/services
and create a file named file-service.ts
and paste in the following code.
To learn more about file upload
in Strapi, see the docs.
1import { getAuthToken } from "@/data/services/get-token";
2import { mutateData } from "@/data/services/mutate-data";
3import { getStrapiURL } from "@/lib/utils";
4
5export async function fileDeleteService(imageId: string) {
6 const authToken = await getAuthToken();
7 if (!authToken) throw new Error("No auth token found");
8
9 const data = await mutateData("DELETE", `/api/upload/files/${imageId}`);
10 return data;
11}
12
13export async function fileUploadService(image: any) {
14 const authToken = await getAuthToken();
15 if (!authToken) throw new Error("No auth token found");
16
17 const baseUrl = getStrapiURL();
18 const url = new URL("/api/upload", baseUrl);
19
20 const formData = new FormData();
21 formData.append("files", image, image.name);
22
23 try {
24 const response = await fetch(url, {
25 headers: { Authorization: `Bearer ${authToken}` },
26 method: "POST",
27 body: formData,
28 });
29
30 const dataResponse = await response.json();
31
32 return dataResponse;
33 } catch (error) {
34 console.error("Error uploading image:", error);
35 throw error;
36 }
37}
Finally, navigate back to profile-image-form.tsx
file and uncomment previously commented code. And make sure to add action={formAction}
to our form element.
The following code should look like the code below.
1"use client";
2import React from "react";
3import { useActionState} from "react";
4
5import { cn } from "@/lib/utils";
6
7import { uploadProfileImageAction } from "@/data/actions/profile-actions";
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 ProfileImageFormProps {
15 id: string;
16 url: string;
17 alternativeText: string;
18}
19
20const initialState = {
21 message: null,
22 data: null,
23 strapiErrors: null,
24 zodErrors: null,
25};
26
27export function ProfileImageForm({
28 data,
29 className,
30}: {
31 data: Readonly<ProfileImageFormProps>;
32 className?: string;
33}) {
34 const uploadProfileImageWithIdAction = uploadProfileImageAction.bind(
35 null,
36 data?.id
37 );
38
39 const [formState, formAction] = useActionState(
40 uploadProfileImageWithIdAction,
41 initialState
42 );
43
44 return (
45 <form className={cn("space-y-4", className)} action={formAction}>
46 <div className="">
47 <ImagePicker
48 id="image"
49 name="image"
50 label="Profile Image"
51 defaultValue={data?.url || ""}
52 />
53 <ZodErrors error={formState?.zodErrors?.image} />
54 <StrapiErrors error={formState?.strapiErrors} />
55 </div>
56 <div className="flex justify-end">
57 <SubmitButton text="Update Image" loadingText="Saving Image" />
58 </div>
59 </form>
60 );
61}
Before we can test whether our image uploader works, let's change our Strapi setting to enable file upload and deletion.
These options are under Settings
=> USERS & PERMISSIONS PLUGIN
=> Roles
=> Authenticated
=> Upload
.
Check both upload
and destroy
boxes.
Let's test out our upload functionality.
Excellent, we now have our file upload working.
The following post will look at handling our video summary generation.
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.
This project has been updated to use Next.js 15 and Strapi 5.
If you have any questions, feel free to stop by at our Discord Community for our daily "open office hours" from 12:30 PM CST to 1:30 PM CST.
If you have a suggestion or find a mistake in the post, please open an issue on the GitHub repository.
You can also find the blog post content in the Strapi Blog.
Feel free to make PRs to fix any issues you find in the project, or let me know if you have any questions.
Happy coding!