In the previous tutorial, we completed our Home Page. Now we'll build out our Sign In and Sign Up pages and implement the authentication logic to enable user registration and login.
- Part 1: Learn Next.js by building a website
- Part 2: Building Out The Hero Section of the homepage
- Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
- Part 4: How to handle login and Authentification in Next.js
- Part 5: Building out the Dashboard page and upload file using NextJS server actions
- Part 6: Get Video Transcript with AI SDK
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
Let's start by creating our routes.
How To Group Routes In Next.js
Next.js allows us to group routes and create shared layouts. You can read more here. For our use case, we'll create a route group called auth
. To create a route group, you create a folder with a name enclosed in parentheses.
Our folder structure will look like the following.
- A folder named
(auth)
- Inside the
(auth)
folder, create two additional folders:signin
andsignup
, each with a blankpage.tsx
file - Finally, inside the
(auth)
folder, create a file calledlayout.tsx
to serve as our shared layout between thesignin
andsignup
pages
You can learn more about the layout.tsx
file in Next.js docs here
Now that we have our basic folder structure, let's create the following components.
In the layout.tsx
file, paste the following code.
1export default function AuthLayout({
2 children,
3}: {
4 readonly children: React.ReactNode;
5}) {
6 return (
7 <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
8 {children}
9 </div>
10 );
11}
Paste the following code in the signin/page.tsx
file.
1export default function SignInRoute() {
2 return <div>Sign In Route</div>;
3}
Paste the following code in the signup/page.tsx
file.
1export default function SingUpRoute() {
2 return <div>Sign Up Route</div>;
3}
After creating these components, you should be able to navigate to our signin
page via the link.
Building Our Signin and Signup Form
Let's navigate to app/components
and create a new folder called forms
. Inside that folder, create two new files: signin-form.tsx
and signup-form.tsx
, and paste the following code for the respective components.
signin-form.tsx
1"use client";
2import Link from "next/link";
3
4import {
5 CardTitle,
6 CardDescription,
7 CardHeader,
8 CardContent,
9 CardFooter,
10 Card,
11} from "@/components/ui/card";
12
13import { Label } from "@/components/ui/label";
14import { Input } from "@/components/ui/input";
15import { Button } from "@/components/ui/button";
16
17const styles = {
18 container: "w-full max-w-md",
19 header: "space-y-1",
20 title: "text-3xl font-bold text-pink-500",
21 content: "space-y-4",
22 fieldGroup: "space-y-2",
23 footer: "flex flex-col",
24 button: "w-full",
25 prompt: "mt-4 text-center text-sm",
26 link: "ml-2 text-pink-500",
27};
28
29export function SigninForm() {
30 return (
31 <div className={styles.container}>
32 <form>
33 <Card>
34 <CardHeader className={styles.header}>
35 <CardTitle className={styles.title}>Sign In</CardTitle>
36 <CardDescription>
37 Enter your details to sign in to your account
38 </CardDescription>
39 </CardHeader>
40 <CardContent className={styles.content}>
41 <div className={styles.fieldGroup}>
42 <Label htmlFor="email">Email</Label>
43 <Input
44 id="identifier"
45 name="identifier"
46 type="text"
47 placeholder="username or email"
48 />
49 </div>
50 <div className={styles.fieldGroup}>
51 <Label htmlFor="password">Password</Label>
52 <Input
53 id="password"
54 name="password"
55 type="password"
56 placeholder="password"
57 />
58 </div>
59 </CardContent>
60 <CardFooter className={styles.footer}>
61 <Button className={styles.button}>Sign In</Button>
62 </CardFooter>
63 </Card>
64 <div className={styles.prompt}>
65 Don't have an account?
66 <Link className={styles.link} href="signup">
67 Sign Up
68 </Link>
69 </div>
70 </form>
71 </div>
72 );
73}
signup-form.tsx
1"use client";
2import Link from "next/link";
3
4import {
5 CardTitle,
6 CardDescription,
7 CardHeader,
8 CardContent,
9 CardFooter,
10 Card,
11} from "@/components/ui/card";
12
13import { Label } from "@/components/ui/label";
14import { Input } from "@/components/ui/input";
15import { Button } from "@/components/ui/button";
16
17const styles = {
18 container: "w-full max-w-md",
19 header: "space-y-1",
20 title: "text-3xl font-bold text-pink-500",
21 content: "space-y-4",
22 fieldGroup: "space-y-2",
23 footer: "flex flex-col",
24 button: "w-full",
25 prompt: "mt-4 text-center text-sm",
26 link: "ml-2 text-pink-500",
27};
28
29export function SignupForm() {
30 return (
31 <div className={styles.container}>
32 <form>
33 <Card>
34 <CardHeader className={styles.header}>
35 <CardTitle className={styles.title}>Sign Up</CardTitle>
36 <CardDescription>
37 Enter your details to create a new account
38 </CardDescription>
39 </CardHeader>
40 <CardContent className={styles.content}>
41 <div className={styles.fieldGroup}>
42 <Label htmlFor="username">Username</Label>
43 <Input
44 id="username"
45 name="username"
46 type="text"
47 placeholder="username"
48 />
49 </div>
50 <div className={styles.fieldGroup}>
51 <Label htmlFor="email">Email</Label>
52 <Input
53 id="email"
54 name="email"
55 type="email"
56 placeholder="name@example.com"
57 />
58 </div>
59 <div className={styles.fieldGroup}>
60 <Label htmlFor="password">Password</Label>
61 <Input
62 id="password"
63 name="password"
64 type="password"
65 placeholder="password"
66 />
67 </div>
68 </CardContent>
69 <CardFooter className={styles.footer}>
70 <Button className={styles.button}>Sign Up</Button>
71 </CardFooter>
72 </Card>
73 <div className={styles.prompt}>
74 Have an account?
75 <Link className={styles.link} href="signin">
76 Sign In
77 </Link>
78 </div>
79 </form>
80 </div>
81 );
82}
Since we're using Shadcn UI, we need to install the card
, input
, and label
components that we're using in the code above.
You can learn more about Shadcn UI here
We can install the components by running the following command:
npx shadcn@latest add card label input
Now that we've installed our components, let's navigate to app/(auth)/signin/page.tsx
and import our newly created SigninForm
component.
The final code should look like the following.
1import { SigninForm } from "@/components/forms/signin-form";
2
3export default function SingInRoute() {
4 return <SigninForm />;
5}
Let's do the same for the signup/page.tsx
file by updating it as follows:
1import { SignupForm } from "@/components/forms/signup-form";
2
3export default function SingUoRoute() {
4 return <SignupForm />;
5}
Now restart your frontend Next.js application. You should see the following when navigating to the Sign In page:
Excellent! We now have both of our forms. Before diving into the details of implementing form submission via Server Actions, here are some great resources to learn more about the process: MDN HTML Forms and specific to Next.js Server Action & Mutations
Now let's dive into building out our SignupForm
.
Form Submission Using Next.js Server Actions
We'll first focus on our SignupForm
, and then, after we understand how things work, we'll make the same changes to our SigninForm
.
While building our form, let's consider these key concepts in the context of Next.js:
- We can get form values via the
name
attribute in theinput
fields inside the form - The form will have an action attribute pointing to a server action
- When we click the submit
button
, it will submit the form and trigger our action - We'll be able to access our data inside the server action via FormData
- Inside the server action, our business logic will handle signup via our Strapi backend
Let's start by defining our first Next.js server action. Navigate to src/app/data
and create a new folder called actions
with index.ts
and auth.ts
files.
Inside our newly created auth.ts
file, let's paste the following code:
1"use server";
2
3export async function registerUserAction(formData: FormData) {
4 console.log("Hello From Register User Action");
5}
And in the index.ts
file, paste the following:
1import { registerUserAction } from "./auth";
2
3export const actions = {
4 auth: {
5 registerUserAction,
6 },
7};
Now let's import our registerUserAction
in our signup-form.tsx
file and add it to our form action.
1import { actions } from "@/data/actions";
Update the form action attribute with the following:
1{
2 /* rest of our code */
3}
4 <form action={actions.auth.registerUserAction}>
5{
6 /* rest of our code */
7}
Now, you should be able to click the Sign Up
button, and we should see our console log in our terminal since it's being executed on the server.
Excellent! Now that we know how to trigger our server action
via form submission, let's examine how we can access our form data via FormData.
How To Access FormData Inside Next.js Server Action
For additional reading, I recommend checking out this post about FormData on MDN, but we'll be using the get
method to retrieve our values.
When we submit our form, the values will be passed to our server action via FormData using the input name
attribute as the key for our value.
For example, we can retrieve our data using FormData.get("username")
for the following input.
Let's update our registerUserAction
action in the auth.ts
file with the following code:
1"use server";
2
3export async function registerUserAction(formData: FormData) {
4 console.log("Hello From Register User Action");
5
6 const fields = {
7 username: formData.get("username") as string,
8 password: formData.get("password") as string,
9 email: formData.get("email") as string,
10 };
11
12 console.log("#############");
13 console.log(fields);
14 console.log("#############");
15}
Now, fill out the fields in the Signup form and click the Sign Up button. You should see the following console log in your terminal.
Hello From Register User Action
#############
{
username: 'testuser',
password: 'testuser',
email: 'testuser@email.com'
}
#############
We can now get our data in our server action
, but how do we return or validate it?
That's what we'll cover in our next section.
How To Get Form State With useActionState Hook
We'll use React's useActionState
hook to return data from our server action
. You can learn more here.
Back in the signup-form.tsx
file.
We'll first import our useActionState
hook from react-dom
:
1import { useActionState } from "react";
Now, let's create a variable to store our initial state:
1const INITIAL_STATE = {
2 data: null,
3};
Now let's use our useActionState
hook:
1const [formState, formAction] = useActionState(
2 actions.auth.registerUserAction,
3 INITIAL_STATE
4);
5
6console.log("## will render on client ##");
7console.log(formState);
8console.log("###########################");
And update the form
action attribute with the following:
1 <form action={formAction}>
The completed code should look like the following.
1"use client";
2import { useActionState } from "react";
3import Link from "next/link";
4import { actions } from "@/data/actions";
5
6import {
7 CardTitle,
8 CardDescription,
9 CardHeader,
10 CardContent,
11 CardFooter,
12 Card,
13} from "@/components/ui/card";
14
15import { Label } from "@/components/ui/label";
16import { Input } from "@/components/ui/input";
17import { Button } from "@/components/ui/button";
18
19const styles = {
20 container: "w-full max-w-md",
21 header: "space-y-1",
22 title: "text-3xl font-bold text-pink-500",
23 content: "space-y-4",
24 fieldGroup: "space-y-2",
25 footer: "flex flex-col",
26 button: "w-full",
27 prompt: "mt-4 text-center text-sm",
28 link: "ml-2 text-pink-500",
29};
30
31const INITIAL_STATE = {
32 data: null,
33};
34
35export function SignupForm() {
36 const [formState, formAction] = useActionState(
37 actions.auth.registerUserAction,
38 INITIAL_STATE
39 );
40
41 console.log("## will render on client ##");
42 console.log(formState);
43 console.log("###########################");
44 return (
45 <div className={styles.container}>
46 <form action={formAction}>
47 <Card>
48 <CardHeader className={styles.header}>
49 <CardTitle className={styles.title}>Sign Up</CardTitle>
50 <CardDescription>
51 Enter your details to create a new account
52 </CardDescription>
53 </CardHeader>
54 <CardContent className={styles.content}>
55 <div className={styles.fieldGroup}>
56 <Label htmlFor="username">Username</Label>
57 <Input
58 id="username"
59 name="username"
60 type="text"
61 placeholder="username"
62 />
63 </div>
64 <div className={styles.fieldGroup}>
65 <Label htmlFor="email">Email</Label>
66 <Input
67 id="email"
68 name="email"
69 type="email"
70 placeholder="name@example.com"
71 />
72 </div>
73 <div className={styles.fieldGroup}>
74 <Label htmlFor="password">Password</Label>
75 <Input
76 id="password"
77 name="password"
78 type="password"
79 placeholder="password"
80 />
81 </div>
82 </CardContent>
83 <CardFooter className={styles.footer}>
84 <Button className={styles.button}>Sign Up</Button>
85 </CardFooter>
86 </Card>
87 <div className={styles.prompt}>
88 Have an account?
89 <Link className={styles.link} href="signin">
90 Sign In
91 </Link>
92 </div>
93 </form>
94 </div>
95 );
96}
Finally, we need to update our registerUserAction
action in the auth.ts
file using the following code:
1"use server";
2
3export async function registerUserAction(prevState: any, formData: FormData) {
4 console.log("Hello From Register User Action");
5
6 const fields = {
7 username: formData.get("username") as string,
8 password: formData.get("password") as string,
9 email: formData.get("email") as string,
10 };
11
12 console.log("#############");
13 console.log(fields);
14 console.log("#############");
15
16 return {
17 ...prevState,
18 data: fields,
19 };
20}
We'll fix the use of any
in a bit when we use Zod for validation.
When you submit the form, you should see our data console logged in our frontend via the console.log(formState);
that we have in our signup-form.tsx
file.
This is great! We can pass data to our server action
and return it via useActionState
.
Before we see how to submit our form and sign up via our Strapi backend, let's examine how to handle form validation with Zod.
Form Validation In Next.js with Zod
Zod is a validation library designed for use with TypeScript and JavaScript. In this project we'll be using the newly released Zod 4.
You can reference the following to see what has changed docs.
It offers an expressive syntax for creating complex validation schemas, which makes Zod particularly useful for validating user-generated data, such as information submitted through forms or received from API requests, to ensure the data aligns with your application's expected structures and types.
Let's examine how we can add Zod validation for our forms. We'll choose to do the validation inside our server action
.
Let's start by installing Zod with the following command:
yarn add zod
Once the installation is complete, go to the data directory, create a new folder named validation
, then add a file called auth.ts
with the following code:
1import { z } from "zod";
2
3export const SigninFormSchema = z.object({
4 identifier: z
5 .string()
6 .min(3, "Username or email must be at least 3 characters"),
7 password: z
8 .string()
9 .min(6, "Password must be at least 6 characters")
10 .max(100, "Password must be less than 100 characters"),
11});
12
13export const SignupFormSchema = z.object({
14 username: z
15 .string()
16 .min(3, "Username must be at least 3 characters")
17 .max(20, "Username must be less than 20 characters"),
18 email: z.email("Please enter a valid email address"),
19 password: z
20 .string()
21 .min(6, "Password must be at least 6 characters")
22 .max(100, "Password must be less than 100 characters"),
23});
24
25export type SigninFormValues = z.infer<typeof SigninFormSchema>;
26export type SignupFormValues = z.infer<typeof SignupFormSchema>;
27
28export type FormState = {
29 success?: boolean;
30 message?: string;
31 data?: {
32 identifier?: string;
33 username?: string;
34 email?: string;
35 password?: string;
36 };
37 strapiErrors?: {
38 status: number;
39 name: string;
40 message: string;
41 details?: Record<string, string[]>;
42 } | null;
43 zodErrors?: {
44 identifier?: string[];
45 username?: string[];
46 email?: string[];
47 password?: string[];
48 } | null;
49};
Here, we're setting up two Zod schemas—one for signing in and one for signing up—along with a FormState type to help us track what's happening with our form.
- SigninFormSchema validates that the identifier (username or email) is at least 3 characters long, and the password is between 6 and 100 characters
- SignupFormSchema validates that the username is between 3 and 20 characters, the email is valid, and the password follows the same 6–100 character rule
We also have some TypeScript types:
- SigninFormValues and SignupFormValues give us the exact shape of valid form data for each schema
- FormState keeps track of:
- Whether the request was successful and any message we want to show
- The actual form data we're working with
- Any Zod validation errors before the form even gets sent
- Any Strapi API errors we'll handle once we hook this up to our backend later in the tutorial
Now, let's update our registerUserAction
to use our schema to validate our fields by making the following changes:
1"use server";
2
3import { z } from "zod";
4import { SignupFormSchema, type FormState } from "@/data/validation/auth";
5
6export async function registerUserAction(
7 prevState: FormState,
8 formData: FormData
9): Promise<FormState> {
10 console.log("Hello From Register User Action");
11
12 const fields = {
13 username: formData.get("username") as string,
14 password: formData.get("password") as string,
15 email: formData.get("email") as string,
16 };
17
18 const validatedFields = SignupFormSchema.safeParse(fields);
19
20 if (!validatedFields.success) {
21 const flattenedErrors = z.flattenError(validatedFields.error);
22 console.log("Validation failed:", flattenedErrors.fieldErrors);
23 return {
24 success: false,
25 message: "Validation failed",
26 strapiErrors: null,
27 zodErrors: flattenedErrors.fieldErrors,
28 data: {
29 ...prevState.data,
30 ...fields,
31 },
32 };
33 }
34
35 console.log("Validation successful:", validatedFields.data);
36
37 // TODO: WE WILL ADD STRAPI LOGIC HERE LATER
38
39 return {
40 success: true,
41 message: "User registration successful",
42 strapiErrors: null,
43 zodErrors: null,
44 data: {
45 ...prevState.data,
46 ...fields,
47 },
48 };
49}
In the above code, we’re using Zod to validate the data submitted from our user registration form.
The SignupFormSchema.safeParse() method checks the username, password, and email values extracted from the formData.
If validation fails (validatedFields.success is false), we use z.flattenError() to format the errors, log them, and return the previous form state along with a failure message and the field-specific error details.
If validation succeeds, we log the valid data and return the updated form state with a success flag and message.
This validation step ensures that all registration data meets our defined rules before sending out request to Strapi.
Before testing our form, we just have to make one small change inside our signup-form.tsx
file.
First let's import our FormState type:
1import { type FormState } from "@/data/validation/auth";
And update our INITIAL_STATE
with the following:
1const INITIAL_STATE: FormState = {
2 success: false,
3 message: undefined,
4 strapiErrors: null,
5 zodErrors: null,
6};
The completed code should look like the following:
1"use client";
2import { type FormState } from "@/data/validation/auth";
3import { useActionState } from "react";
4import Link from "next/link";
5import { actions } from "@/data/actions";
6
7import {
8 CardTitle,
9 CardDescription,
10 CardHeader,
11 CardContent,
12 CardFooter,
13 Card,
14} from "@/components/ui/card";
15
16import { Label } from "@/components/ui/label";
17import { Input } from "@/components/ui/input";
18import { Button } from "@/components/ui/button";
19
20const styles = {
21 container: "w-full max-w-md",
22 header: "space-y-1",
23 title: "text-3xl font-bold text-pink-500",
24 content: "space-y-4",
25 fieldGroup: "space-y-2",
26 footer: "flex flex-col",
27 button: "w-full",
28 prompt: "mt-4 text-center text-sm",
29 link: "ml-2 text-pink-500",
30};
31
32const INITIAL_STATE: FormState = {
33 success: false,
34 message: undefined,
35 strapiErrors: null,
36 zodErrors: null,
37};
38
39export function SignupForm() {
40 const [formState, formAction] = useActionState(
41 actions.auth.registerUserAction,
42 INITIAL_STATE
43 );
44
45 console.log("## will render on client ##");
46 console.log(formState);
47 console.log("###########################");
48 return (
49 <div className={styles.container}>
50 <form action={formAction}>
51 <Card>
52 <CardHeader className={styles.header}>
53 <CardTitle className={styles.title}>Sign Up</CardTitle>
54 <CardDescription>
55 Enter your details to create a new account
56 </CardDescription>
57 </CardHeader>
58 <CardContent className={styles.content}>
59 <div className={styles.fieldGroup}>
60 <Label htmlFor="username">Username</Label>
61 <Input
62 id="username"
63 name="username"
64 type="text"
65 placeholder="username"
66 />
67 </div>
68 <div className={styles.fieldGroup}>
69 <Label htmlFor="email">Email</Label>
70 <Input
71 id="email"
72 name="email"
73 type="email"
74 placeholder="name@example.com"
75 />
76 </div>
77 <div className={styles.fieldGroup}>
78 <Label htmlFor="password">Password</Label>
79 <Input
80 id="password"
81 name="password"
82 type="password"
83 placeholder="password"
84 />
85 </div>
86 </CardContent>
87 <CardFooter className={styles.footer}>
88 <Button className={styles.button}>Sign Up</Button>
89 </CardFooter>
90 </Card>
91 <div className={styles.prompt}>
92 Have an account?
93 <Link className={styles.link} href="signin">
94 Sign In
95 </Link>
96 </div>
97 </form>
98 </div>
99 );
100}
Let's test our form by not adding any of our fields and submitting it.
Notice we can see our errors in the front end. Let's create a new component called ZodErrors
to help us display them inside our signup-form.tsx
file.
First, navigate to src/app/components/custom
, create a new file called zod-errors.tsx
, and paste it into the following code.
1interface IZodErrorsProps {
2 error?: string[];
3}
4
5export function ZodErrors({ error }: IZodErrorsProps) {
6 if (!error) return null;
7 return error.map((err: string, index: number) => (
8 <div key={index} className="text-pink-500 text-xs italic mt-1 py-2">
9 {err}
10 </div>
11 ));
12}
Now, navigate to src/app/components/forms/signup-form.tsx
and let's use the following component.
We will import and add it to our form and pass the zod errors we are getting back from our formState
.
The updated signup-form.tsx
code should look like the following. Also notice that we are using defaultValue
to populate previously entered field data.
1"use client";
2import { type FormState } from "@/data/validation/auth";
3import { useActionState } from "react";
4import Link from "next/link";
5import { actions } from "@/data/actions";
6
7import {
8 CardTitle,
9 CardDescription,
10 CardHeader,
11 CardContent,
12 CardFooter,
13 Card,
14} from "@/components/ui/card";
15
16import { Label } from "@/components/ui/label";
17import { Input } from "@/components/ui/input";
18import { Button } from "@/components/ui/button";
19
20import { ZodErrors } from "@/components/custom/zod-errors";
21
22const styles = {
23 container: "w-full max-w-md",
24 header: "space-y-1",
25 title: "text-3xl font-bold text-pink-500",
26 content: "space-y-4",
27 fieldGroup: "space-y-2",
28 footer: "flex flex-col",
29 button: "w-full",
30 prompt: "mt-4 text-center text-sm",
31 link: "ml-2 text-pink-500",
32};
33
34const INITIAL_STATE: FormState = {
35 success: false,
36 message: undefined,
37 strapiErrors: null,
38 zodErrors: null,
39};
40
41export function SignupForm() {
42 const [formState, formAction] = useActionState(
43 actions.auth.registerUserAction,
44 INITIAL_STATE
45 );
46
47 console.log("## will render on client ##");
48 console.log(formState);
49 console.log("###########################");
50 return (
51 <div className={styles.container}>
52 <form action={formAction}>
53 <Card>
54 <CardHeader className={styles.header}>
55 <CardTitle className={styles.title}>Sign Up</CardTitle>
56 <CardDescription>
57 Enter your details to create a new account
58 </CardDescription>
59 </CardHeader>
60 <CardContent className={styles.content}>
61 <div className={styles.fieldGroup}>
62 <Label htmlFor="username">Username</Label>
63 <Input
64 id="username"
65 name="username"
66 type="text"
67 placeholder="username"
68 />
69 <ZodErrors error={formState?.zodErrors?.username} />
70 </div>
71 <div className={styles.fieldGroup}>
72 <Label htmlFor="email">Email</Label>
73 <Input
74 id="email"
75 name="email"
76 type="email"
77 placeholder="name@example.com"
78 />
79 <ZodErrors error={formState?.zodErrors?.email} />
80 </div>
81 <div className={styles.fieldGroup}>
82 <Label htmlFor="password">Password</Label>
83 <Input
84 id="password"
85 name="password"
86 type="password"
87 placeholder="password"
88 />
89 <ZodErrors error={formState?.zodErrors?.password} />
90 </div>
91 </CardContent>
92 <CardFooter className={styles.footer}>
93 <Button className={styles.button}>Sign Up</Button>
94 </CardFooter>
95 </Card>
96 <div className={styles.prompt}>
97 Have an account?
98 <Link className={styles.link} href="signin">
99 Sign In
100 </Link>
101 </div>
102 </form>
103 </div>
104 );
105}
Now, restart your frontend Next.js project and try submitting the form without entering any data; you should see the following errors.
Although our errors show up correctly, notice that are previous field entry disapears. We need to let our form to access previos values, we can do this by using defaultValue
and passing our previous state via formState.
1 defaultValue={formState?.data?.username || ""}
The updated code will look like the following:
1"use client";
2import { type FormState } from "@/data/validation/auth";
3import { useActionState } from "react";
4import Link from "next/link";
5import { actions } from "@/data/actions";
6
7import {
8 CardTitle,
9 CardDescription,
10 CardHeader,
11 CardContent,
12 CardFooter,
13 Card,
14} from "@/components/ui/card";
15
16import { Label } from "@/components/ui/label";
17import { Input } from "@/components/ui/input";
18import { Button } from "@/components/ui/button";
19
20import { ZodErrors } from "@/components/custom/zod-errors";
21
22const styles = {
23 container: "w-full max-w-md",
24 header: "space-y-1",
25 title: "text-3xl font-bold text-pink-500",
26 content: "space-y-4",
27 fieldGroup: "space-y-2",
28 footer: "flex flex-col",
29 button: "w-full",
30 prompt: "mt-4 text-center text-sm",
31 link: "ml-2 text-pink-500",
32};
33
34const INITIAL_STATE: FormState = {
35 success: false,
36 message: undefined,
37 strapiErrors: null,
38 zodErrors: null,
39};
40
41export function SignupForm() {
42 const [formState, formAction] = useActionState(
43 actions.auth.registerUserAction,
44 INITIAL_STATE
45 );
46
47 console.log("## will render on client ##");
48 console.log(formState);
49 console.log("###########################");
50 return (
51 <div className={styles.container}>
52 <form action={formAction}>
53 <Card>
54 <CardHeader className={styles.header}>
55 <CardTitle className={styles.title}>Sign Up</CardTitle>
56 <CardDescription>
57 Enter your details to create a new account
58 </CardDescription>
59 </CardHeader>
60 <CardContent className={styles.content}>
61 <div className={styles.fieldGroup}>
62 <Label htmlFor="username">Username</Label>
63 <Input
64 id="username"
65 name="username"
66 type="text"
67 placeholder="username"
68 defaultValue={formState?.data?.username || ""}
69 />
70 <ZodErrors error={formState?.zodErrors?.username} />
71 </div>
72 <div className={styles.fieldGroup}>
73 <Label htmlFor="email">Email</Label>
74 <Input
75 id="email"
76 name="email"
77 type="email"
78 placeholder="name@example.com"
79 defaultValue={formState?.data?.email || ""}
80 />
81 <ZodErrors error={formState?.zodErrors?.email} />
82 </div>
83 <div className={styles.fieldGroup}>
84 <Label htmlFor="password">Password</Label>
85 <Input
86 id="password"
87 name="password"
88 type="password"
89 placeholder="password"
90 defaultValue={formState?.data?.password || ""}
91 />
92 <ZodErrors error={formState?.zodErrors?.password} />
93 </div>
94 </CardContent>
95 <CardFooter className={styles.footer}>
96 <Button className={styles.button}>Sign Up</Button>
97 </CardFooter>
98 </Card>
99 <div className={styles.prompt}>
100 Have an account?
101 <Link className={styles.link} href="signin">
102 Sign In
103 </Link>
104 </div>
105 </form>
106 </div>
107 );
108}
Notice now we are able to keep our previos fields entries.
Nice. Now that we have our form validation working, let's move on and create a service that will handle our Strapi Auth Login.
Authentication with Next.js and Strapi
Now, let's implement Strapi authentication by registering our user via our Strapi API. You can find the process explained here
The basic overview,
- request to register user to Strapi
- after the user is created, we will get back a JWT token
- save the cookie via the
httpOnly
cookie - redirect the user to the
dashboard
. - handle Strapi errors if any exist
Let's start by creating a service that will handle Strapi User Registration.
Navigate to src/app/data
and create a new folder called services
inside. Create the file auth.ts
and paste it into the following code.
1import { getStrapiURL } from "@/lib/utils";
2import type { TStrapiResponse, TImage } from "@/types";
3import { actions } from "@/data/actions";
4import qs from "qs";
5
6type TRegisterUser = {
7 username: string;
8 password: string;
9 email: string;
10};
11
12type TLoginUser = {
13 identifier: string;
14 password: string;
15};
16
17type TAuthUser = {
18 id: number;
19 documentId: string;
20 username: string;
21 email: string;
22 firstName?: string;
23 lastName?: string;
24 bio?: string;
25 image?: TImage;
26 credits?: number;
27 provider: string;
28 confirmed: boolean;
29 blocked: boolean;
30 createdAt: string;
31 updatedAt: string;
32 publishedAt: string;
33};
34
35type TAuthResponse = {
36 jwt: string;
37 user: TAuthUser;
38};
39
40type TAuthServiceResponse = TAuthResponse | TStrapiResponse<null>;
41
42// Type guard functions
43export function isAuthError(
44 response: TAuthServiceResponse
45): response is TStrapiResponse<null> {
46 return "error" in response;
47}
48
49export function isAuthSuccess(
50 response: TAuthServiceResponse
51): response is TAuthResponse {
52 return "jwt" in response;
53}
54
55const baseUrl = getStrapiURL();
56
57export async function registerUserService(
58 userData: TRegisterUser
59): Promise<TAuthServiceResponse | undefined> {
60 const url = new URL("/api/auth/local/register", baseUrl);
61
62 try {
63 const response = await fetch(url, {
64 method: "POST",
65 headers: {
66 "Content-Type": "application/json",
67 },
68 body: JSON.stringify({ ...userData }),
69 });
70
71 const data = (await response.json()) as TAuthServiceResponse;
72 console.dir(data, { depth: null });
73 return data;
74 } catch (error) {
75 console.error("Registration Service Error:", error);
76 return undefined;
77 }
78}
79
80export async function loginUserService(
81 userData: TLoginUser
82): Promise<TAuthServiceResponse> {
83 const url = new URL("/api/auth/local", baseUrl);
84
85 try {
86 const response = await fetch(url, {
87 method: "POST",
88 headers: {
89 "Content-Type": "application/json",
90 },
91 body: JSON.stringify({ ...userData }),
92 });
93
94 return response.json() as Promise<TAuthServiceResponse>;
95 } catch (error) {
96 console.error("Login Service Error:", error);
97 throw error;
98 }
99}
Now, inside the services
folder, create an index.ts
file with the following code to export our auth services:
1import { registerUserService, loginUserService } from "./auth";
2export const services = {
3 auth: {
4 registerUserService,
5 loginUserService,
6 },
7};
This authentication service module provides a complete interface for handling user authentication with a Strapi backend. Here's what it does:
Type Definitions:
- Defines TypeScript types for user registration (
TRegisterUser
), login (TLoginUser
), and authenticated user data (TAuthUser
) - Creates a union type
TAuthServiceResponse
that handles both successful authentication responses and error responses - Includes type guard functions (
isAuthError
,isAuthSuccess
) to safely distinguish between success and error responses
Core Authentication Functions:
registerUserService
- Handles user registration by sending POST requests to Strapi's/api/auth/local/register
endpointloginUserService
- Manages user login through Strapi's/api/auth/local
endpoint
Key Features:
- Proper error handling with try/catch blocks and detailed error responses
- Type-safe responses that can be either successful authentication data or structured error information
- Integration with Strapi's authentication endpoints for a headless CMS setup
This service layer abstracts all the authentication complexity and provides a clean, typed interface for the rest of the application to handle user registration, login, and profile data retrieval.
Now, let's create an index.ts
to keep with our pattern and export our services:
1import { registerUserService, loginUserService } from "./auth";
2
3export const services = {
4 auth: {
5 registerUserService,
6 loginUserService,
7 },
8};
This includes both our registerUserService
and loginUserService
, which is based on what you can find in the Strapi Docs here.
Now, we can utilize our registerUserService
service inside our auth-actions.ts
file. Let's navigate to that file and add the following to our registerUserAction
.
Let's import our services.
1import { services } from "@/data/services";
2import { isAuthError } from "@/data/services/auth";
And the following:
1 const responseData = await services.auth.registerUserService(
2 validatedFields.data
3 );
4
5 if (!responseData) {
6 return {
7 success: false,
8 message: "Ops! Something went wrong. Please try again.",
9 strapiErrors: null,
10 zodErrors: null,
11 data: {
12 ...prevState.data,
13 ...fields,
14 },
15 };
16 }
17
18 // Check if responseData is an error response
19 if (isAuthError(responseData)) {
20 return {
21 success: false,
22 message: "Failed to Register.",
23 strapiErrors: responseData.error,
24 zodErrors: null,
25 data: {
26 ...prevState.data,
27 ...fields,
28 },
29 };
30 }
31
32 console.log("#############");
33 console.log("User Registered Successfully", responseData);
34 console.log("#############");
35
36 return {
37 success: true,
38 message: "User registration successful",
39 strapiErrors: null,
40 zodErrors: null,
41 data: {
42 ...prevState.data,
43 ...fields,
44 },
45 };
46}
The complete code should look like the following:
1"use server";
2
3import { z } from "zod";
4import { services } from "@/data/services";
5import { isAuthError } from "@/data/services/auth";
6
7import { SignupFormSchema, type FormState } from "@/data/validation/auth";
8
9export async function registerUserAction(
10 prevState: FormState,
11 formData: FormData
12): Promise<FormState> {
13 console.log("Hello From Register User Action");
14
15 const fields = {
16 username: formData.get("username") as string,
17 password: formData.get("password") as string,
18 email: formData.get("email") as string,
19 };
20
21 const validatedFields = SignupFormSchema.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.auth.registerUserService(
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 // Check if responseData is an error response
58 if (isAuthError(responseData)) {
59 return {
60 success: false,
61 message: "Failed to Register.",
62 strapiErrors: responseData.error,
63 zodErrors: null,
64 data: {
65 ...prevState.data,
66 ...fields,
67 },
68 };
69 }
70
71 console.log("#############");
72 console.log("User Registered Successfully", responseData);
73 console.log("#############");
74
75 return {
76 success: true,
77 message: "User registration successful",
78 strapiErrors: null,
79 zodErrors: null,
80 data: {
81 ...prevState.data,
82 ...fields,
83 },
84 };
85}
Notice in the code above, inside of our return we are now returning strapiErrors
. We will see how to render them in the front in just a moment, but first, let's test our form and see if we can see our jwt
token being returned in our terminal console and Strapi user in our admin panel.
Nice, we are able to create a new user and register. Before moving on to handling redirects and setting the httpOnly
cookie, let's create a component to render our Strapi Errors and Make our Submit Button cooler.
Handling Strapi Errors in Next.js
Now that we have implemented Next.js Strapi authentication, let's ensure that we handle some Strapi errors. Navigate to src/app/components/custom
, create a new file named strapi-errors.tsx
, and paste the following code.
1type TStrapiError = {
2 status: number;
3 name: string;
4 message: string;
5 details?: Record<string, string[]>;
6};
7
8interface IStrapiErrorsProps {
9 error?: TStrapiError | null;
10}
11
12export function StrapiErrors({ error }: IStrapiErrorsProps) {
13 if (!error?.message) return null;
14 return (
15 <div className="text-pink-500 text-md italic py-2">{error.message}</div>
16 );
17}
Now navigate back to our signup-form.tsx
file, import our newly created component, and add it right after our' submit' button.
1import { StrapiErrors } from "@/components/custom/strapi-errors";
1<CardFooter className={styles.footer}>
2 <Button className={styles.button}>Sign Up</Button>
3 <StrapiErrors error={formState?.strapiErrors} />
4</CardFooter>
Let's test and see if we can see our Strapi Errors. Try creating another user with an email you used to make your first user.
You should see the following message.
Let's improve our submit
button by adding a pending state and making it prettier.
How To Handle Pending State In Next.js With useFormStatus
When we submit a form, it may be in a pending state, and we would like to show a spinner for a better user experience.
Let's look at how we can accomplish this by creating a SubmitButton
component that will utilize the useFormStatus
hook. The Next.js docs provide more details here.
The useFormStatus
Hook gives you the status information of the last form submission. We will use that to get the status of our form and show our loading spinner.
Let's start by navigating to app/components/custom
, creating the following file name submit-button.tsx
, and adding the following code.
1"use client";
2import { useFormStatus } from "react-dom";
3import { cn } from "@/lib/utils";
4import { Button } from "@/components/ui/button";
5import { Loader2 } from "lucide-react";
6
7function Loader({ text }: { readonly text: string }) {
8 return (
9 <div className="flex items-center space-x-2">
10 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
11 <p>{text}</p>
12 </div>
13 );
14}
15
16interface IButtonProps {
17 text: string;
18 loadingText: string;
19 className?: string;
20 loading?: boolean;
21}
22
23export function SubmitButton({
24 text,
25 loadingText,
26 loading,
27 className,
28}: IButtonProps) {
29 const status = useFormStatus();
30 return (
31 <Button
32 type="submit"
33 aria-disabled={status.pending || loading}
34 disabled={status.pending || loading}
35 className={cn(className)}
36 >
37 {status.pending || loading ? <Loader text={loadingText} /> : text}
38 </Button>
39 );
40}
Now that we have our new SubmitButton component, let's use it inside our signup-form.tsx
file.
Let's replace our boring button
with the following, but first, ensure you import it.
1import { SubmitButton } from "@/components/custom/submit-button";
Inside our CardFooter
, let's update you with the following:
1<CardFooter className={styles.footer}>
2 <SubmitButton className="w-full" text="Sign Up" loadingText="Loading" />
3 <StrapiErrors error={formState?.strapiErrors} />
4</CardFooter>
Now let's test our new beautiful button.
It's beautiful.
The last two things we need to do are to look at how to set our JWT token as a httpOnly
cookie, handle redirects, and set up protected routes with the middleware.ts
file.
How To Set HTTP Only Cookie in Next.js
We will add this logic to our src/data/actions/auth
file in our registerUserAction
function.
You can learn more about setting cookies in Next.js on their docs here
Let's make the following change inside of our registerUserAction
file.
First import cookies
and redirect
from Next:
1import { cookies } from "next/headers";
2import { redirect } from "next/navigation";
Next, create a variable to store our cookies
config.
1const config = {
2 maxAge: 60 * 60 * 24 * 7, // 1 week
3 path: "/",
4 domain: process.env.HOST ?? "localhost",
5 httpOnly: true,
6 secure: process.env.NODE_ENV === "production",
7};
Finally, use the following code to set the cookie.
1const cookieStore = await cookies();
2cookieStore.set("jwt", responseData.jwt, config);
3redirect("/dashboard");
You can now remove the following last return since we will never reach it due to our redirect.
1return {
2 success: true,
3 message: "User registration successful",
4 strapiErrors: null,
5 zodErrors: null,
6 data: {
7 ...prevState.data,
8 ...fields,
9 },
10};
The final code should look like the following. Notice we are using the redirect
function from Next.js to redirect the user to the dashboard
page; you can learn more here.
1"use server";
2
3import { z } from "zod";
4import { cookies } from "next/headers";
5import { redirect } from "next/navigation";
6import { services } from "@/data/services";
7import { isAuthError } from "@/data/services/auth";
8
9import { SignupFormSchema, type FormState } from "@/data/validation/auth";
10
11const config = {
12 maxAge: 60 * 60 * 24 * 7, // 1 week
13 path: "/",
14 domain: process.env.HOST ?? "localhost",
15 httpOnly: true,
16 secure: process.env.NODE_ENV === "production",
17};
18
19export async function registerUserAction(
20 prevState: FormState,
21 formData: FormData
22): Promise<FormState> {
23 console.log("Hello From Register User Action");
24
25 const fields = {
26 username: formData.get("username") as string,
27 password: formData.get("password") as string,
28 email: formData.get("email") as string,
29 };
30
31 const validatedFields = SignupFormSchema.safeParse(fields);
32
33 if (!validatedFields.success) {
34 const flattenedErrors = z.flattenError(validatedFields.error);
35 console.log("Validation failed:", flattenedErrors.fieldErrors);
36 return {
37 success: false,
38 message: "Validation failed",
39 strapiErrors: null,
40 zodErrors: flattenedErrors.fieldErrors,
41 data: {
42 ...prevState.data,
43 ...fields,
44 },
45 };
46 }
47
48 console.log("Validation successful:", validatedFields.data);
49
50 const responseData = await services.auth.registerUserService(
51 validatedFields.data
52 );
53
54 if (!responseData) {
55 return {
56 success: false,
57 message: "Ops! Something went wrong. Please try again.",
58 strapiErrors: null,
59 zodErrors: null,
60 data: {
61 ...prevState.data,
62 ...fields,
63 },
64 };
65 }
66
67 // Check if responseData is an error response
68 if (isAuthError(responseData)) {
69 return {
70 success: false,
71 message: "Failed to Register.",
72 strapiErrors: responseData.error,
73 zodErrors: null,
74 data: {
75 ...prevState.data,
76 ...fields,
77 },
78 };
79 }
80
81 console.log("#############");
82 console.log("User Registered Successfully", responseData);
83 console.log("#############");
84
85 const cookieStore = await cookies();
86 cookieStore.set("jwt", responseData.jwt, config);
87 redirect("/dashboard");
88}
Notice we are redirecting to our dashboard
route; Let's make this page now.
Inside the app folder, create a new folder named (protected)
. Within that, add a dashboard
folder, and inside it create a page.tsx
file.
Paste the following code into page.tsx:
1export default function DashboardRoute() {
2 return (
3 <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
4 <h1>Dashboard</h1>
5 </div>
6 );
7}
Let's create another user and see our redirect in action and our cookies set.
You can see here that we are saving it as an httpOnly
cookie.
Nice. We are almost done with the authentication flow, but we still have a small issue. If I remove the cookie, we are still able to navigate to the dashboard,
but that should be a protected route.
How To Protect Your Routes in Next.js via Middleware
We will use Next.js middleware
to protect our routes. You can learn more here.
In the src
folder, create a file called middleware.ts
and paste it into the following code.
1import { NextResponse } from "next/server";
2import type { NextRequest } from "next/server";
3import { services } from "@/data/services";
4
5// Define an array of protected routes
6const protectedRoutes: string[] = ["/dashboard", "/dashboard/*"];
7
8// Helper function to check if a path is protected
9function isProtectedRoute(path: string): boolean {
10 if (!path || protectedRoutes.length === 0) return false;
11 return protectedRoutes.some((route) => {
12 // For exact matches
13 if (!route.includes("*")) {
14 return path === route;
15 }
16
17 // For wildcard routes (e.g., /dashboard/*)
18 const basePath = route.replace("/*", "");
19 return path === basePath || path.startsWith(`${basePath}/`);
20 });
21}
22
23export async function middleware(request: NextRequest) {
24 const currentPath = request.nextUrl.pathname;
25
26 // Only validate authentication for protected routes
27 if (isProtectedRoute(currentPath)) {
28 try {
29 // Validate user using getUserMe service - this checks:
30 // 1. Token exists and is valid
31 // 2. User exists in database
32 // 3. User account is active (not blocked/deleted)
33 const userResponse = await services.auth.getUserMeService();
34
35 // If user validation fails, redirect to signin
36 if (!userResponse.success || !userResponse.data) {
37 return NextResponse.redirect(new URL("/signin", request.url));
38 }
39
40 // User is valid, continue to protected route
41 return NextResponse.next();
42 } catch (error) {
43 // If getUserMe throws an error, redirect to signin
44 console.error("Middleware authentication error:", error);
45 return NextResponse.redirect(new URL("/signin", request.url));
46 }
47 }
48
49 return NextResponse.next();
50}
51// Configure matcher for better performance
52export const config = {
53 matcher: [
54 // Match /dashboard and any path under /dashboard
55 /*
56 * Match all request paths except for the ones starting with:
57 * - api (API routes)
58 * - _next/static (static files)
59 * - _next/image (image optimization files)
60 * - favicon.ico (favicon file)
61 */
62 "/((?!api|_next/static|_next/image|favicon.ico).*)",
63 "/dashboard",
64 "/dashboard/:path*",
65 ],
66};
In the code above, we are using the getUserMeService
let's go ahead and add it in our src/data/services/auth.ts
.
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 try {
10 const response = await fetch(url.href, {
11 method: "GET",
12 headers: {
13 "Content-Type": "application/json",
14 Authorization: `Bearer ${authToken}`,
15 },
16 });
17 const data = await response.json();
18 if (data.error)
19 return {
20 success: false,
21 data: undefined,
22 error: data.error,
23 status: response.status,
24 };
25 return {
26 success: true,
27 data: data,
28 error: undefined,
29 status: response.status,
30 };
31 } catch (error) {
32 console.log(error);
33 return {
34 success: false,
35 data: undefined,
36 error: {
37 status: 500,
38 name: "NetworkError",
39 message:
40 error instanceof Error
41 ? error.message
42 : "An unexpected error occurred",
43 details: {},
44 },
45 status: 500,
46 };
47 }
48}
It is responsible for checking in with Strapi and confirming authenticated user.
Don't forget to export it from the index.ts
file:
1import {
2 registerUserService,
3 loginUserService,
4 getUserMeService,
5} from "./auth";
6export const services = {
7 auth: {
8 registerUserService,
9 loginUserService,
10 getUserMeService,
11 },
12};
The complete file should look like the following:
1import { getStrapiURL } from "@/lib/utils";
2import type { TStrapiResponse, TImage } from "@/types";
3import { actions } from "@/data/actions";
4import qs from "qs";
5
6type TRegisterUser = {
7 username: string;
8 password: string;
9 email: string;
10};
11
12type TLoginUser = {
13 identifier: string;
14 password: string;
15};
16
17type TAuthUser = {
18 id: number;
19 documentId: string;
20 username: string;
21 email: string;
22 firstName?: string;
23 lastName?: string;
24 bio?: string;
25 image?: TImage;
26 credits?: number;
27 provider: string;
28 confirmed: boolean;
29 blocked: boolean;
30 createdAt: string;
31 updatedAt: string;
32 publishedAt: string;
33};
34
35type TAuthResponse = {
36 jwt: string;
37 user: TAuthUser;
38};
39
40type TAuthServiceResponse = TAuthResponse | TStrapiResponse<null>;
41
42// Type guard functions
43export function isAuthError(
44 response: TAuthServiceResponse
45): response is TStrapiResponse<null> {
46 return "error" in response;
47}
48
49export function isAuthSuccess(
50 response: TAuthServiceResponse
51): response is TAuthResponse {
52 return "jwt" in response;
53}
54
55const baseUrl = getStrapiURL();
56
57export async function registerUserService(
58 userData: TRegisterUser
59): Promise<TAuthServiceResponse | undefined> {
60 const url = new URL("/api/auth/local/register", baseUrl);
61
62 try {
63 const response = await fetch(url, {
64 method: "POST",
65 headers: {
66 "Content-Type": "application/json",
67 },
68 body: JSON.stringify({ ...userData }),
69 });
70
71 const data = (await response.json()) as TAuthServiceResponse;
72 console.dir(data, { depth: null });
73 return data;
74 } catch (error) {
75 console.error("Registration Service Error:", error);
76 return undefined;
77 }
78}
79
80export async function loginUserService(
81 userData: TLoginUser
82): Promise<TAuthServiceResponse> {
83 const url = new URL("/api/auth/local", baseUrl);
84
85 try {
86 const response = await fetch(url, {
87 method: "POST",
88 headers: {
89 "Content-Type": "application/json",
90 },
91 body: JSON.stringify({ ...userData }),
92 });
93
94 return response.json() as Promise<TAuthServiceResponse>;
95 } catch (error) {
96 console.error("Login Service Error:", error);
97 throw error;
98 }
99}
100
101export async function getUserMeService(): Promise<TStrapiResponse<TAuthUser>> {
102 const authToken = await actions.auth.getAuthTokenAction();
103
104 if (!authToken)
105 return { success: false, data: undefined, error: undefined, status: 401 };
106
107 const url = new URL("/api/users/me", baseUrl);
108
109 try {
110 const response = await fetch(url.href, {
111 method: "GET",
112 headers: {
113 "Content-Type": "application/json",
114 Authorization: `Bearer ${authToken}`,
115 },
116 });
117 const data = await response.json();
118 if (data.error)
119 return {
120 success: false,
121 data: undefined,
122 error: data.error,
123 status: response.status,
124 };
125 return {
126 success: true,
127 data: data,
128 error: undefined,
129 status: response.status,
130 };
131 } catch (error) {
132 console.log(error);
133 return {
134 success: false,
135 data: undefined,
136 error: {
137 status: 500,
138 name: "NetworkError",
139 message:
140 error instanceof Error
141 ? error.message
142 : "An unexpected error occurred",
143 details: {},
144 },
145 status: 500,
146 };
147 }
148}
Notice that our getUserMeService
is calling getAuthTokenAction()
. In thesrc/data/actions/auth.ts
let's add the following code:
1export async function getAuthTokenAction() {
2 const cookieStore = await cookies();
3 const authToken = cookieStore.get("jwt")?.value;
4 return authToken;
5}
6
7export async function logoutUserAction() {
8 const cookieStore = await cookies();
9 cookieStore.set("jwt", "", { ...config, maxAge: 0 });
10 redirect("/");
11}
Notice tha we also added logoutUserAction()
it will be responsible for clearing out the cookie when logging out.
The final file should look like the following:
1"use server";
2
3import { z } from "zod";
4import { cookies } from "next/headers";
5import { redirect } from "next/navigation";
6import { services } from "@/data/services";
7import { isAuthError } from "@/data/services/auth";
8
9import { SignupFormSchema, type FormState } from "@/data/validation/auth";
10
11const config = {
12 maxAge: 60 * 60 * 24 * 7, // 1 week
13 path: "/",
14 domain: process.env.HOST ?? "localhost",
15 httpOnly: true,
16 secure: process.env.NODE_ENV === "production",
17};
18
19export async function registerUserAction(
20 prevState: FormState,
21 formData: FormData
22): Promise<FormState> {
23 console.log("Hello From Register User Action");
24
25 const fields = {
26 username: formData.get("username") as string,
27 password: formData.get("password") as string,
28 email: formData.get("email") as string,
29 };
30
31 const validatedFields = SignupFormSchema.safeParse(fields);
32
33 if (!validatedFields.success) {
34 const flattenedErrors = z.flattenError(validatedFields.error);
35 console.log("Validation failed:", flattenedErrors.fieldErrors);
36 return {
37 success: false,
38 message: "Validation failed",
39 strapiErrors: null,
40 zodErrors: flattenedErrors.fieldErrors,
41 data: {
42 ...prevState.data,
43 ...fields,
44 },
45 };
46 }
47
48 console.log("Validation successful:", validatedFields.data);
49
50 const responseData = await services.auth.registerUserService(
51 validatedFields.data
52 );
53
54 if (!responseData) {
55 return {
56 success: false,
57 message: "Ops! Something went wrong. Please try again.",
58 strapiErrors: null,
59 zodErrors: null,
60 data: {
61 ...prevState.data,
62 ...fields,
63 },
64 };
65 }
66
67 // Check if responseData is an error response
68 if (isAuthError(responseData)) {
69 return {
70 success: false,
71 message: "Failed to Register.",
72 strapiErrors: responseData.error,
73 zodErrors: null,
74 data: {
75 ...prevState.data,
76 ...fields,
77 },
78 };
79 }
80
81 console.log("#############");
82 console.log("User Registered Successfully", responseData);
83 console.log("#############");
84
85 const cookieStore = await cookies();
86 cookieStore.set("jwt", responseData.jwt, config);
87 redirect("/dashboard");
88}
89
90export async function logoutUserAction() {
91 const cookieStore = await cookies();
92 cookieStore.set("jwt", "", { ...config, maxAge: 0 });
93 redirect("/");
94}
95
96export async function getAuthTokenAction() {
97 const cookieStore = await cookies();
98 const authToken = cookieStore.get("jwt")?.value;
99 return authToken;
100}
Don't forget to add to the export in the index.ts
file:
1import {
2 registerUserAction,
3 logoutUserAction,
4 getAuthTokenAction,
5} from "./auth";
6
7export const actions = {
8 auth: {
9 registerUserAction,
10 logoutUserAction,
11 getAuthTokenAction,
12 },
13};
Now let's create a log out button that we can use in our dashboard. Navigate to src/components/custom
and create a file called logout-button
and paste in the following:
1import { actions } from "@/data/actions";
2import { LogOut } from "lucide-react";
3
4export function LogoutButton() {
5 return (
6 <form action={actions.auth.logoutUserAction}>
7 <button type="submit">
8 <LogOut className="w-6 h-6 hover:text-primary" />
9 </button>
10 </form>
11 );
12}
Now let's add it in our app/(protected)dashboard/page.tsx
file:
1import { LogoutButton } from "@/components/custom/logout-button";
2
3export default function DashboardRoute() {
4 return (
5 <div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
6 <h1>Dashboard</h1>
7 <LogoutButton />
8 </div>
9 );
10}
Now let's create a new user, logout, and try to navigate to the the dashboard, you will notice, we will be redirected to our login route.
Nice. Great job.
Nice. Now that we know our middleware
is working, let's work on hooking up our SigninForm
instead of going step by step like we did. Since we will basically do the same thing we did in the SignupForm,
we are just going to paste in the completed code.
Let's update the sign-form.tsx
file with the following.
1"use client";
2import { actions } from "@/data/actions";
3import { useActionState } from "react";
4import { type FormState } from "@/data/validation/auth";
5
6import Link from "next/link";
7
8import {
9 CardTitle,
10 CardDescription,
11 CardHeader,
12 CardContent,
13 CardFooter,
14 Card,
15} from "@/components/ui/card";
16
17import { Label } from "@/components/ui/label";
18import { Input } from "@/components/ui/input";
19import { SubmitButton } from "@/components/custom/submit-button";
20
21import { ZodErrors } from "@/components/custom/zod-errors";
22import { StrapiErrors } from "@/components/custom/strapi-errors";
23
24const styles = {
25 container: "w-full max-w-md",
26 header: "space-y-1",
27 title: "text-3xl font-bold text-pink-500",
28 content: "space-y-4",
29 fieldGroup: "space-y-2",
30 footer: "flex flex-col",
31 button: "w-full",
32 prompt: "mt-4 text-center text-sm",
33 link: "ml-2 text-pink-500",
34};
35
36const INITIAL_STATE: FormState = {
37 success: false,
38 message: undefined,
39 strapiErrors: null,
40 zodErrors: null,
41};
42
43export function SigninForm() {
44 const [formState, formAction] = useActionState(
45 actions.auth.loginUserAction,
46 INITIAL_STATE
47 );
48
49 return (
50 <div className={styles.container}>
51 <form action={formAction}>
52 <Card>
53 <CardHeader className={styles.header}>
54 <CardTitle className={styles.title}>Sign In</CardTitle>
55 <CardDescription>
56 Enter your details to sign in to your account
57 </CardDescription>
58 </CardHeader>
59 <CardContent className={styles.content}>
60 <div className={styles.fieldGroup}>
61 <Label htmlFor="email">Username or Email</Label>
62 <Input
63 id="identifier"
64 name="identifier"
65 type="text"
66 placeholder="username or email"
67 defaultValue={formState?.data?.identifier || ""}
68 />
69 <ZodErrors error={formState?.zodErrors?.identifier} />
70 </div>
71 <div className={styles.fieldGroup}>
72 <Label htmlFor="password">Password</Label>
73 <Input
74 id="password"
75 name="password"
76 type="password"
77 placeholder="password"
78 defaultValue={formState?.data?.password || ""}
79 />
80 <ZodErrors error={formState?.zodErrors?.password} />
81 </div>
82 </CardContent>
83 <CardFooter className={styles.footer}>
84 <SubmitButton
85 className="w-full"
86 text="Sign In"
87 loadingText="Loading"
88 />
89 <StrapiErrors error={formState?.strapiErrors} />
90 </CardFooter>
91 </Card>
92 <div className={styles.prompt}>
93 Don't have an account?
94 <Link className={styles.link} href="signup">
95 Sign Up
96 </Link>
97 </div>
98 </form>
99 </div>
100 );
101}
Our form expects our loginUserAction
, let's add it in our src/data/actions/auth.ts
file:
1export async function loginUserAction(
2 prevState: FormState,
3 formData: FormData
4): Promise<FormState> {
5 console.log("Hello From Login User Action");
6
7 const fields = {
8 identifier: formData.get("identifier") as string,
9 password: formData.get("password") as string,
10 };
11
12 console.dir(fields);
13
14 const validatedFields = SigninFormSchema.safeParse(fields);
15
16 if (!validatedFields.success) {
17 const flattenedErrors = z.flattenError(validatedFields.error);
18 console.log("Validation failed:", flattenedErrors.fieldErrors);
19 return {
20 success: false,
21 message: "Validation failed",
22 strapiErrors: null,
23 zodErrors: flattenedErrors.fieldErrors,
24 data: {
25 ...prevState.data,
26 ...fields,
27 },
28 };
29 }
30
31 console.log("Validation successful:", validatedFields.data);
32
33 const responseData = await services.auth.loginUserService(
34 validatedFields.data
35 );
36
37 if (!responseData) {
38 return {
39 success: false,
40 message: "Ops! Something went wrong. Please try again.",
41 strapiErrors: null,
42 zodErrors: null,
43 data: {
44 ...prevState.data,
45 ...fields,
46 },
47 };
48 }
49
50 // Check if responseData is an error response
51 if (isAuthError(responseData)) {
52 return {
53 success: false,
54 message: "Failed to Login.",
55 strapiErrors: responseData.error,
56 zodErrors: null,
57 data: {
58 ...prevState.data,
59 ...fields,
60 },
61 };
62 }
63
64 console.log("#############");
65 console.log("User Login Successfully", responseData);
66 console.log("#############");
67
68 const cookieStore = await cookies();
69 cookieStore.set("jwt", responseData.jwt, config);
70 redirect("/dashboard");
71}
Our loginUserAction
is expecting the SigninFormSchema
, let's add the following import:
1import {
2 SignupFormSchema,
3 SigninFormSchema,
4 type FormState,
5} from "@/data/validation/auth";
And don't forget to add it you our export in the index.ts
file:
1import {
2 registerUserAction,
3 loginUserAction,
4 logoutUserAction,
5 getAuthTokenAction,
6} from "./auth";
7
8export const actions = {
9 auth: {
10 registerUserAction,
11 loginUserAction,
12 logoutUserAction,
13 getAuthTokenAction,
14 },
15};
The completed file will look like the following:
1"use server";
2
3import { z } from "zod";
4import { cookies } from "next/headers";
5import { redirect } from "next/navigation";
6import { services } from "@/data/services";
7import { isAuthError } from "@/data/services/auth";
8
9import {
10 SignupFormSchema,
11 SigninFormSchema,
12 type FormState,
13} from "@/data/validation/auth";
14
15const config = {
16 maxAge: 60 * 60 * 24 * 7, // 1 week
17 path: "/",
18 domain: process.env.HOST ?? "localhost",
19 httpOnly: true,
20 secure: process.env.NODE_ENV === "production",
21};
22
23export async function registerUserAction(
24 prevState: FormState,
25 formData: FormData
26): Promise<FormState> {
27 console.log("Hello From Register User Action");
28
29 const fields = {
30 username: formData.get("username") as string,
31 password: formData.get("password") as string,
32 email: formData.get("email") as string,
33 };
34
35 const validatedFields = SignupFormSchema.safeParse(fields);
36
37 if (!validatedFields.success) {
38 const flattenedErrors = z.flattenError(validatedFields.error);
39 console.log("Validation failed:", flattenedErrors.fieldErrors);
40 return {
41 success: false,
42 message: "Validation failed",
43 strapiErrors: null,
44 zodErrors: flattenedErrors.fieldErrors,
45 data: {
46 ...prevState.data,
47 ...fields,
48 },
49 };
50 }
51
52 console.log("Validation successful:", validatedFields.data);
53
54 const responseData = await services.auth.registerUserService(
55 validatedFields.data
56 );
57
58 if (!responseData) {
59 return {
60 success: false,
61 message: "Ops! Something went wrong. Please try again.",
62 strapiErrors: null,
63 zodErrors: null,
64 data: {
65 ...prevState.data,
66 ...fields,
67 },
68 };
69 }
70
71 // Check if responseData is an error response
72 if (isAuthError(responseData)) {
73 return {
74 success: false,
75 message: "Failed to Register.",
76 strapiErrors: responseData.error,
77 zodErrors: null,
78 data: {
79 ...prevState.data,
80 ...fields,
81 },
82 };
83 }
84
85 console.log("#############");
86 console.log("User Registered Successfully", responseData);
87 console.log("#############");
88
89 const cookieStore = await cookies();
90 cookieStore.set("jwt", responseData.jwt, config);
91 redirect("/dashboard");
92}
93
94export async function loginUserAction(
95 prevState: FormState,
96 formData: FormData
97): Promise<FormState> {
98 console.log("Hello From Login User Action");
99
100 const fields = {
101 identifier: formData.get("identifier") as string,
102 password: formData.get("password") as string,
103 };
104
105 console.dir(fields);
106
107 const validatedFields = SigninFormSchema.safeParse(fields);
108
109 if (!validatedFields.success) {
110 const flattenedErrors = z.flattenError(validatedFields.error);
111 console.log("Validation failed:", flattenedErrors.fieldErrors);
112 return {
113 success: false,
114 message: "Validation failed",
115 strapiErrors: null,
116 zodErrors: flattenedErrors.fieldErrors,
117 data: {
118 ...prevState.data,
119 ...fields,
120 },
121 };
122 }
123
124 console.log("Validation successful:", validatedFields.data);
125
126 const responseData = await services.auth.loginUserService(
127 validatedFields.data
128 );
129
130 if (!responseData) {
131 return {
132 success: false,
133 message: "Ops! Something went wrong. Please try again.",
134 strapiErrors: null,
135 zodErrors: null,
136 data: {
137 ...prevState.data,
138 ...fields,
139 },
140 };
141 }
142
143 // Check if responseData is an error response
144 if (isAuthError(responseData)) {
145 return {
146 success: false,
147 message: "Failed to Login.",
148 strapiErrors: responseData.error,
149 zodErrors: null,
150 data: {
151 ...prevState.data,
152 ...fields,
153 },
154 };
155 }
156
157 console.log("#############");
158 console.log("User Login Successfully", responseData);
159 console.log("#############");
160
161 const cookieStore = await cookies();
162 cookieStore.set("jwt", responseData.jwt, config);
163 redirect("/dashboard");
164}
165
166export async function logoutUserAction() {
167 const cookieStore = await cookies();
168 cookieStore.set("jwt", "", { ...config, maxAge: 0 });
169 redirect("/");
170}
171
172export async function getAuthTokenAction() {
173 const cookieStore = await cookies();
174 const authToken = cookieStore.get("jwt")?.value;
175 return authToken;
176}
Nice, our signin form should now work, let's try it out:
Conclusion
In this Next.js tutorial, we successfully built the Sign In and Sign Up pages for a Next.js application.
We implemented custom Sign In and Sign Up forms with error handling and integrated them with a backend using server actions.
Using useActionState and Zod for form validation ensured data integrity and provided user feedback.
We also covered setting up httpOnly cookies for secure authentication and protecting routes through Next.js middleware, establishing a solid foundation for user authentication flows in Next.js applications.
Thank you for your time, and I hope you are enjoying these tutorials.
If you have any questions, you can ask them in the comments or stop by Strapi's open office
on Discord from 12:30 pm CST to 1:30 pm CST Monday through Friday.
See you in the next post, where we will work on building our dashboard.
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