Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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.
Let's start by creating our routes.
Next, we can group our routes and create shared layouts; you can read more here, but for our use case, we will create a route group called auth
, to make a route a group, you will create a folder whose name will be between parentheses.
Our folder structure will look like the following.
(auth)
(auth)
folder, create two additional folders, signin
and signup
, with a blank page.tsx
file.(auth)
folder, create a file called layout.tsx
to function as our shared layout between our signin
and signup
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.
1
2
3
4
5
6
7
8
9
export default function AuthLayout({ children }: {
readonly children: React.ReactNode;
}) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
{children}
</div>
);
}
Paste the following code in the signin/page.tsx
file.
1
2
3
export default function SignInRoute() {
return <div>Sign In Route</div>;
}
Paste the following code in the signup/page.tsx
file.
1
2
3
export default function SingUpRoute() {
return <div>Sign Up Route</div>;
}
After creating the following components you should be able to navigate to our signin
page via the link.
Inside src/app
, create the following.
Great. Let's now work on our signin
and signup
forms.
Let's navigate to app/components
and create a new folder called forms
. Inside that folder, create two new files called signin-form.tsx
and signup-form.tsx
and paste the following code for the respective components.
signin-form.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
"use client";
import Link from "next/link";
import {
CardTitle,
CardDescription,
CardHeader,
CardContent,
CardFooter,
Card,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export function SigninForm() {
return (
<div className="w-full max-w-md">
<form>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-3xl font-bold">Sign In</CardTitle>
<CardDescription>
Enter your details to sign in to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="identifier"
name="identifier"
type="text"
placeholder="username or email"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="password"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col">
<button className="w-full">Sign In</button>
</CardFooter>
</Card>
<div className="mt-4 text-center text-sm">
Don't have an account?
<Link className="underline ml-2" href="signup">
Sign Up
</Link>
</div>
</form>
</div>
);
}
signup-form.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
"use client";
import Link from "next/link";
import {
CardTitle,
CardDescription,
CardHeader,
CardContent,
CardFooter,
Card,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
export function SignupForm() {
return (
<div className="w-full max-w-md">
<form>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-3xl font-bold">Sign Up</CardTitle>
<CardDescription>
Enter your details to create a new account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="password"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col">
<button className="w-full">Sign Up</button>
</CardFooter>
</Card>
<div className="mt-4 text-center text-sm">
Have an account?
<Link className="underline ml-2" href="signin">
Sing In
</Link>
</div>
</form>
</div>
);
}
Since we are using Shadcn UI, we need to install the card
, input
, and label
components since we are using them in the code above.
You can learn more about Shadcn UI here
We can install the components by running the following code.
npx shadcn@latest add card label input
Now that we have installed our components, let's navigate to app/(auth)/signin/page.tsx
and import and add our newly created SigninForm
component.
The final code should look like the following.
1
2
3
4
5
import { SigninForm } from "@/components/forms/signin-form";
export default function SingInRoute() {
return <SigninForm />;
}
Let's do the same inside the signup/page.tsx
file by updating the following.
1
2
3
4
5
import { SignupForm } from "@/components/forms/signup-form";
export default function SingUoRoute() {
return <SignupForm />;
}
Now restart your frontend Next.js application. You should see the following when navigating the Sign In page.
Nice, we now have both of our forms. Before getting into the details of how to implement our 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 in in building out our SignupForm
.
We will first focus on our SignupForm
, and then, after we understand how things work, we will make the same changes inside our SigninForm
.
While building our form, let's consider these items in the context of Next.js.
name
attribute in the input
fields inside the form.button
with the submit, it will submit the form and trigger our action.Let's start by defining our first Next.js server actions. Navigate to src/app/data
and create a new folder called actions
and a file name auth-actions.ts
.
Inside our newly created file, let's paste the following code.
1
2
3
4
5
"use server";
export async function registerUserAction(formData: FormData) {
console.log("Hello From Register User Action");
}
Now let's import our registerUserAction
inside our signup-form.tsx
file and add it to our form action.
1
import { registerUserAction } from "@/data/actions/auth-actions";
Update the form attribute with the following:
1
2
3
4
5
6
7
8
9
{
/* rest of our code */
}
<form action={registerUserAction}>
{/* rest of our code */}
</form>;
{
/* rest of our code */
}
Now, you should be able to click the Sign Up
button, and we should see our console log in our terminal since it is being executed on the server.
Nice. We don't know if we can trigger our server action
via our form submission. Let's examine how we can access our form data via our FormData.
For additional reading, I recommend checking out this post about FormData on MDN, but we will be using the get
method to get our values.
When we submit our form, the values will be passed down to our server action via the form data using the input name
attribute as the key to our value.
For example, we can retrieve our data using FormData.get("username")
for the following input.
With the following code, let's update our registerUserAction
action in the auth-actions.ts
file.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"use server";
export async function registerUserAction(formData: FormData) {
console.log("Hello From Register User Action");
const fields = {
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
};
console.log("#############");
console.log(fields);
console.log("#############");
}
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: 'Monkey1234!',
email: 'testuser@email.com'
}
#############
We can now get our data in our server action
, but how do we return or validate it?
Well, that is what we will do in our next section.
We will use React's useActionState
hook to return data from our server action
. You can learn more here.
Let's first start in the signup-form.tsx
file.
We will first import our useActionState
hook from react-dom
.
1
import { useActionState } from "react";
Now, let's create a variable to store our initial state.
1
2
3
const INITIAL_STATE = {
data: null,
};
Now let's use our useActionState
hook.
1
const [formState, formAction] = useActionState(registerUserAction, INITIAL_STATE);
And update the form
action attribute with the following.
1
<form action={formAction}>
The completed code should look like the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { registerUserAction } from "@/data/actions/auth-actions";
import {
CardTitle,
CardDescription,
CardHeader,
CardContent,
CardFooter,
Card,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
const INITIAL_STATE = {
data: null,
};
export function SignupForm() {
const [formState, formAction] = useActionState(registerUserAction, INITIAL_STATE);
console.log("## will render on client ##");
console.log(formState);
console.log("###########################");
return (
<div className="w-full max-w-md">
<form action={formAction}>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-3xl font-bold">Sign Up</CardTitle>
<CardDescription>
Enter your details to create a new account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
placeholder="username"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="password"
/>
</div>
</CardContent>
<CardFooter className="flex flex-col">
<button className="w-full">Sign Up</button>
</CardFooter>
</Card>
<div className="mt-4 text-center text-sm">
Have an account?
<Link className="underline ml-2" href="signin">
Sing In
</Link>
</div>
</form>
</div>
);
}
Finally, we have to update our registerUserAction
action in the auth-actions.ts
file using the following code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
"use server";
export async function registerUserAction(prevState: any, formData: FormData) {
console.log("Hello From Register User Action");
const fields = {
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
};
console.log(fields);
return {
...prevState,
data: fields,
};
}
When you submit the form, you should see our data console logged in our frontend via our console.log(formState);
that we have in our signup-form.tsx
file.
This is great. We are able to pass data to our server action
and return it via useActionState
.
Before we see how to submit our form and sign in via our Strapi backend, let's examine how to handle form validation with Zod.
You can learn more about Zod on their website here.
Zod is a validation library designed for use with TypeScript and JavaScript.
It offers an expressive syntax for creating complex validation schema, 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 will choose to do the validation inside of our server action
.
Let's start by installing Zod with the following command.
yarn add zod
Once the installation is complete, restart your app, navigate to our auth-actions.ts
file, and import it with the following command.
1
import { z } from "zod";
Next, let's define our schema. You can learn more about Zod schemas here.
1
2
3
4
5
6
7
8
9
10
11
const schemaRegister = z.object({
username: z.string().min(3).max(20, {
message: "Username must be between 3 and 20 characters",
}),
password: z.string().min(6).max(100, {
message: "Password must be between 6 and 100 characters",
}),
email: z.string().email({
message: "Please enter a valid email address",
}),
});
Here, we are adding simple validation and message.
Now, let's update our registerUserAction
to use our schema to validate our fields by making the following changes.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export async function registerUserAction(prevState: any, formData: FormData) {
console.log("Hello From Register User Action");
const validatedFields = schemaRegister.safeParse({
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
strapiErrors: null,
message: "Missing Fields. Failed to Register.",
};
}
return {
...prevState,
data: "ok",
};
}
We are using Zod in the above code to validate our user registration data.
The schemaRegister.safeParse
function validates username, password, and email fields extracted from formData.
If validation fails (indicated by validatedFields.success being false), the function returns the previous state, Zod validation errors (zodErrors), and a failure message.
If validation succeeds, it returns the previous state updated with a success indicator.
This Zod validation process ensures that user data meets the application's requirements before proceeding.
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.
1
2
3
4
5
6
7
8
export function ZodErrors({ error }: { error: string[] }) {
if (!error) return null;
return error.map((err: string, index: number) => (
<div key={index} className="text-pink-500 text-xs italic mt-1 py-2">
{err}
</div>
));
}
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { registerUserAction } from "@/data/actions/auth-actions";
import {
CardTitle,
CardDescription,
CardHeader,
CardContent,
CardFooter,
Card,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ZodErrors } from "@/components/custom/zod-errors";
const INITIAL_STATE = {
data: null,
};
export function SignupForm() {
const [formState, formAction] = useActionState(
registerUserAction,
INITIAL_STATE
);
console.log("## will render on client ##");
console.log(formState);
console.log("###########################");
return (
<div className="w-full max-w-md">
<form action={formAction}>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-3xl font-bold">Sign Up</CardTitle>
<CardDescription>
Enter your details to create a new account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="username">Username</Label>
<Input
id="username"
name="username"
type="text"
placeholder="username"
/>
<ZodErrors error={formState?.zodErrors?.username} />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="name@example.com"
/>
<ZodErrors error={formState?.zodErrors?.email} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="password"
/>
<ZodErrors error={formState?.zodErrors?.password} />
</div>
</CardContent>
<CardFooter className="flex flex-col">
<button className="w-full">Sign Up</button>
</CardFooter>
</Card>
<div className="mt-4 text-center text-sm">
Have an account?
<Link className="underline ml-2" href="signin">
Sing In
</Link>
</div>
</form>
</div>
);
}
Now, restart your frontend Next.js project and try submitting the form without entering any data; you should see the following errors.
Nice. We don't know if our form validation works, so let's move on and create a service that will handle our Strapi Auth Login.
Now, let's implement Strapi authentication by registering our user via our Strapi API. You can find the process explained here
The basic overview,
httpOnly
cookiedashboard
.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-service.ts
and paste it into the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { getStrapiURL } from "@/lib/utils";
interface RegisterUserProps {
username: string;
password: string;
email: string;
}
interface LoginUserProps {
identifier: string;
password: string;
}
const baseUrl = getStrapiURL();
export async function registerUserService(userData: RegisterUserProps) {
const url = new URL("/api/auth/local/register", baseUrl);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...userData }),
});
return response.json();
} catch (error) {
console.error("Registration Service Error:", error);
}
}
export async function loginUserService(userData: LoginUserProps) {
const url = new URL("/api/auth/local", baseUrl);
try {
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ ...userData }),
});
return response.json();
} catch (error) {
console.error("Login Service Error:", error);
throw error;
}
}
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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const responseData = await registerUserService(validatedFields.data);
if (!responseData) {
return {
...prevState,
strapiErrors: null,
zodErrors: null,
message: "Ops! Something went wrong. Please try again.",
};
}
if (responseData.error) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Failed to Register.",
};
}
Don't forget to import our registerUserService
with the following.
1
import { registerUserService } from "@/data/services/auth-service";
The completed code should look like the following.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
"use server";
import { z } from "zod";
import { registerUserService } from "@/data/services/auth-service";
const schemaRegister = z.object({
username: z.string().min(3).max(20, {
message: "Username must be between 3 and 20 characters",
}),
password: z.string().min(6).max(100, {
message: "Password must be between 6 and 100 characters",
}),
email: z.string().email({
message: "Please enter a valid email address",
}),
});
export async function registerUserAction(prevState: any, formData: FormData) {
console.log("Hello From Register User Action");
const validatedFields = schemaRegister.safeParse({
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
strapiErrors: null,
message: "Missing Fields. Failed to Register.",
};
}
const responseData = await registerUserService(validatedFields.data);
if (!responseData) {
return {
...prevState,
strapiErrors: null,
zodErrors: null,
message: "Ops! Something went wrong. Please try again.",
};
}
if (responseData.error) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Failed to Register.",
};
}
console.log("#############");
console.log("User Registered Successfully", responseData.jwt);
console.log("#############");
}
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.
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.
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.
1
2
3
4
5
6
7
8
9
10
interface StrapiErrorsProps {
message: string | null;
name: string;
status: string | null;
}
export function StrapiErrors( { error }: { readonly error: StrapiErrorsProps }) {
if (!error?.message) return null;
return <div className="text-pink-500 text-md italic py-2">{error.message}</div>;
}
Now navigate back to our signup-form.tsx
file, import our newly created component, and add it right after our' submit' button.
1
import { StrapiErrors } from "@/components/custom/strapi-errors";
1
2
3
4
<CardFooter className="flex flex-col">
<button className="w-full">Sign Up</button>
<StrapiErrors error={formState?.strapiErrors} />
</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.
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
"use client";
import { useFormStatus } from "react-dom";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
function Loader({ text }: { readonly text: string }) {
return (
<div className="flex items-center space-x-2">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
<p>{text}</p>
</div>
);
}
interface SubmitButtonProps {
text: string;
loadingText: string;
className?: string;
loading?: boolean;
}
export function SubmitButton({
text,
loadingText,
loading,
className,
}: Readonly<SubmitButtonProps>) {
const status = useFormStatus();
return (
<Button
type="submit"
aria-disabled={status.pending || loading}
disabled={status.pending || loading}
className={cn(className)}
>
{status.pending || loading ? <Loader text={loadingText} /> : text}
</Button>
);
}
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.
1
import { SubmitButton } from "@/components/custom/submit-button";
Inside our CardFooter
, let's update you with the following:
1
2
3
4
<CardFooter className="flex flex-col">
<SubmitButton className="w-full" text="Sign Up" loadingText="Loading" />
<StrapiErrors error={formState?.strapiErrors} />
</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.
We will add this logic to our auth-actions.ts
file and 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
from Next:
1
import { cookies } from "next/headers";
Next, create a variable to store our cookies
config.
1
2
3
4
5
6
7
const config = {
maxAge: 60 * 60 * 24 * 7, // 1 week
path: "/",
domain: process.env.HOST ?? "localhost",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
};
Finally, use the following code to set the cookie.
1
2
const cookieStore = await cookies();
cookieStore.set("jwt", responseData.jwt, config);
Finally, let's add a redirect to our dashboard
; first, we must create the page, so let's do that now.
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
"use server";
import { z } from "zod";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { registerUserService } from "@/data/services/auth-service";
const config = {
maxAge: 60 * 60 * 24 * 7, // 1 week
path: "/",
domain: process.env.HOST ?? "localhost",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
};
const schemaRegister = z.object({
username: z.string().min(3).max(20, {
message: "Username must be between 3 and 20 characters",
}),
password: z.string().min(6).max(100, {
message: "Password must be between 6 and 100 characters",
}),
email: z.string().email({
message: "Please enter a valid email address",
}),
});
export async function registerUserAction(prevState: any, formData: FormData) {
const validatedFields = schemaRegister.safeParse({
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
strapiErrors: null,
message: "Missing Fields. Failed to Register.",
};
}
const responseData = await registerUserService(validatedFields.data);
if (!responseData) {
return {
...prevState,
strapiErrors: null,
zodErrors: null,
message: "Ops! Something went wrong. Please try again.",
};
}
if (responseData.error) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Failed to Register.",
};
}
const cookieStore = await cookies();
cookieStore.set("jwt", responseData.jwt, config);
redirect("/dashboard");
}
Inside the app
folder, create the dashboard
folder with a page.tsx
file containing the following code.
1
2
3
4
5
6
7
export default function DashboardRoute() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<h1>Dashboard</h1>
</div>
);
}
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.
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { getUserMeLoader } from "@/data/services/get-user-me-loader";
// Define an array of protected routes
const protectedRoutes = [
"/dashboard",
// Add more protected routes here
];
// Helper function to check if a path is protected
function isProtectedRoute(path: string): boolean {
return protectedRoutes.some((route) => path.startsWith(route));
}
export async function middleware(request: NextRequest) {
const user = await getUserMeLoader();
const currentPath = request.nextUrl.pathname;
if (isProtectedRoute(currentPath) && user.ok === false) {
return NextResponse.redirect(new URL("/signin", request.url));
}
return NextResponse.next();
}
// Optionally, you can add a matcher to optimize performance
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - api (API routes)
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
*/
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};
In the code above, we are using the getUserMeLoader
function, which we will create in just a minute, to call our Strapi API to see if the user is still logged in.
If so, we will have the user's information. If the user information does not exist, we will redirect the user to the signin page.
Now, navigate to our services
folder and create the following files.
First, let's create a helpful function to get our JWT
token.
Create a file inside the services
folder called get-token.ts
and add the following code.
1
2
3
4
5
6
7
import { cookies } from "next/headers";
export async function getAuthToken() {
const cookieStore = await cookies();
const authToken = cookieStore.get("jwt")?.value;
return authToken;
}
Create another file called get-user-me-loader.ts
and paste it into the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import { getAuthToken } from "./get-token";
import { getStrapiURL } from "@/lib/utils";
export async function getUserMeLoader() {
const baseUrl = getStrapiURL();
const url = new URL("/api/users/me", baseUrl);
const authToken = await getAuthToken();
if (!authToken) return { ok: false, data: null, error: null };
try {
const response = await fetch(url.href, {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${authToken}`,
},
});
const data = await response.json();
if (data.error) return { ok: false, data: null, error: data.error };
return { ok: true, data: data, error: null };
} catch (error) {
console.log(error);
return { ok: false, data: null, error: error };
}
}
Now, restart your application, delete your previous cookie, and try to navigate to dashboard
. We will be redirected back to the `sign-in page.
Nice. Now that we know our middleware
is working, let's make the updates in 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 start in the auth-actions.ts
file and replace it with the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
"use server";
import { z } from "zod";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import {
registerUserService,
loginUserService,
} from "@/data/services/auth-service";
const config = {
maxAge: 60 * 60 * 24 * 7, // 1 week
path: "/",
domain: process.env.HOST ?? "localhost",
httpOnly: true,
secure: process.env.NODE_ENV === "production",
};
const schemaRegister = z.object({
username: z.string().min(3).max(20, {
message: "Username must be between 3 and 20 characters",
}),
password: z.string().min(6).max(100, {
message: "Password must be between 6 and 100 characters",
}),
email: z.string().email({
message: "Please enter a valid email address",
}),
});
export async function registerUserAction(prevState: any, formData: FormData) {
const validatedFields = schemaRegister.safeParse({
username: formData.get("username"),
password: formData.get("password"),
email: formData.get("email"),
});
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
strapiErrors: null,
message: "Missing Fields. Failed to Register.",
};
}
const responseData = await registerUserService(validatedFields.data);
if (!responseData) {
return {
...prevState,
strapiErrors: null,
zodErrors: null,
message: "Ops! Something went wrong. Please try again.",
};
}
if (responseData.error) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Failed to Register.",
};
}
const cookieStore = await cookies();
cookieStore.set("jwt", responseData.jwt, config);
redirect("/dashboard");
}
const schemaLogin = z.object({
identifier: z
.string()
.min(3, {
message: "Identifier must have at least 3 or more characters",
})
.max(20, {
message: "Please enter a valid username or email address",
}),
password: z
.string()
.min(6, {
message: "Password must have at least 6 or more characters",
})
.max(100, {
message: "Password must be between 6 and 100 characters",
}),
});
export async function loginUserAction(prevState: any, formData: FormData) {
const validatedFields = schemaLogin.safeParse({
identifier: formData.get("identifier"),
password: formData.get("password"),
});
if (!validatedFields.success) {
return {
...prevState,
zodErrors: validatedFields.error.flatten().fieldErrors,
message: "Missing Fields. Failed to Login.",
};
}
const responseData = await loginUserService(validatedFields.data);
if (!responseData) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Ops! Something went wrong. Please try again.",
};
}
if (responseData.error) {
return {
...prevState,
strapiErrors: responseData.error,
zodErrors: null,
message: "Failed to Login.",
};
}
console.log(responseData, "responseData");
const cookieStore = await cookies();
cookieStore.set("jwt", responseData.jwt, config);
redirect("/dashboard");
}
export async function logoutAction() {
const cookieStore = await cookies();
cookieStore.set("jwt", "", { ...config, maxAge: 0 });
redirect("/");
}
Next, navigate to our signin-form.tsx
file and paste the following code.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { loginUserAction } from "@/data/actions/auth-actions";
import {
CardTitle,
CardDescription,
CardHeader,
CardContent,
CardFooter,
Card,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { ZodErrors } from "@/components/custom/zod-errors";
import { StrapiErrors } from "@/components/custom/strapi-errors";
import { SubmitButton } from "@/components/custom/submit-button";
const INITIAL_STATE = {
zodErrors: null,
strapiErrors: null,
data: null,
message: null,
};
export function SigninForm() {
const [formState, formAction] = useActionState(loginUserAction, INITIAL_STATE);
return (
<div className="w-full max-w-md">
<form action={formAction}>
<Card>
<CardHeader className="space-y-1">
<CardTitle className="text-3xl font-bold">Sign In</CardTitle>
<CardDescription>
Enter your details to sign in to your account
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="identifier"
name="identifier"
type="text"
placeholder="username or email"
/>
<ZodErrors error={formState?.zodErrors?.identifier} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="password"
/>
<ZodErrors error={formState?.zodErrors?.password} />
</div>
</CardContent>
<CardFooter className="flex flex-col">
<SubmitButton
className="w-full"
text="Sign In"
loadingText="Loading"
/>
<StrapiErrors error={formState?.strapiErrors} />
</CardFooter>
</Card>
<div className="mt-4 text-center text-sm">
Don't have an account?
<Link className="underline ml-2" href="signup">
Sign Up
</Link>
</div>
</form>
</div>
);
}
Finally, let's create a Logout Button. Navigate to app/components/custom
, create a file called logout-button.tsx
and add the following code.
1
2
3
4
5
6
7
8
9
10
11
12
import { logoutAction } from "@/data/actions/auth-actions";
import { LogOut } from "lucide-react";
export function LogoutButton() {
return (
<form action={logoutAction}>
<button type="submit">
<LogOut className="w-6 h-6 hover:text-primary" />
</button>
</form>
);
}
Finally, let's import this button into our app/dashboard/page.tsx
file for now.
The code should look like the following.
1
2
3
4
5
6
7
8
9
10
import { LogoutButton } from "@/components/custom/logout-button";
export default function DashboardRoute() {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-100 dark:bg-gray-900">
<h1>Dashboard</h1>
<LogoutButton />
</div>
);
}
Only some things are in place, so let's test our Sign In page.
Nice. Great job.
Protect your application from common threats by following these best practices:
To ensure your application adheres to industry standards, consider reviewing the Strapi security best practices, which include essential measures such as using the latest stable versions, adding HTTPS and SSL certificates, using strong passwords, taking regular backups, securing web hosts, modifying default settings, and installing security plugins.
By implementing these security measures and following best practices, you'll improve the resilience of your Next.js application against potential threats.
Implementing authentication requires thorough testing to ensure your application is secure and functions correctly.
Writing unit tests for your authentication logic helps catch errors early. Using testing frameworks like Jest, you can create tests for your login and signup API routes. For example, you might test that:
By simulating API calls and checking responses, you can verify that your authentication system handles different scenarios as expected.
When issues arise, common problems include misconfigured middleware, incorrect token handling, or server-side errors. To debug effectively:
By systematically checking each part of your authentication flow, you can identify and fix issues to maintain a secure application.
Preparing your Next.js 14 application for deployment involves ensuring that your server settings are properly configured for security and performance.
To secure your application in production, configure your server appropriately:
By carefully configuring your server settings, you improve the security and reliability of your deployed application.
By securing your Next.js 14 application with strong authentication, you've improved your web app's security and user experience. For more performance and flexibility, consider integrating with Strapi's headless CMS solutions.
Strapi offers headless CMS solutions that enhance performance and flexibility, catering to various business needs. These solutions integrate with any frontend framework and allow content to be published across multiple channels simultaneously, improving site performance, scalability, and providing a personalized experience.
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.
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!