Introduction
In this part, we will create the frontend user interface for our Strapi CMS application using Next.js. Our focus will be on developing a registration page, a login page, and One-time Password (OTP) verification processes for both email and Time-based one-time password (TOTP) through an authenticator app.
Tutorial Outline
This article is divided into two parts:
- Part 1: Setting up 2FA and TOTP within Strapi
- Part 2: Building Authentication Pages using Next.js with QR Code Generation
Tutorial Objective
We will implement the setup for the authenticator app to allow users to easily enable their accounts for secure authentication.
Here are the core pages we will be building to test our Strapi application:
- Registration Page: User registration with our modified registration flow.
- Login Page: Allows users log in with email/password and trigger the OTP or TOTP verification process.
- OTP Verification via Email: Handling the submission of the OTP code sent to the user's email.
- Authenticator App Setup: The users' TOTP setup page has the ability to link an authenticator app by scanning a QR code.
- TOTP Verification: The page that enables users to enter the TOTP code from their authenticator app to complete their login.
Let us start by creating a new Next.js app with App Router and TypeScript enabled:
npx create-next-app@latest front-2fa --use-npm # or --use-yarnSession Management: Installing Iron Session
We will set up Iron Session to manage user sessions in our Next.js application. Iron Session is a lightweight library that is secure, stateless, and cookie-based session. Session data is stored in signed and encrypted cookies which are decoded by the server code in a stateless fashion.
Let us add iron session to our Next.js project:
npm install iron-session
# or
yarn add iron-sessionNext, let us create a new file, src/app/auth/lib.ts for the session configuration options and add the code:
import { SessionOptions } from "iron-session";
export interface SessionData {
username: string;
isLoggedIn: boolean;
jwt?: string;
}
export const defaultSession: SessionData = {
username: "",
isLoggedIn: false,
};
export const sessionOptions: SessionOptions = {
password: "complex_password_at_least_32_characters_long",
cookieName: "strapi-otp-app",
cookieOptions: {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
},
};We create a SessionData interface to structure session data (username, login status, and optional JWT). A defaultSession that initializes the default session values, and sessionOptions configures the session.
We need a way to determine whether a user is logged in. To achieve this, let's create a new file: src/app/auth/actions.ts. This file will contain all the authentication-related server actions.
Next, we'll define a getSession method to retrieve the user’s session data:
"use server";
import { cookies } from "next/headers";
import { SessionData, defaultSession, sessionOptions } from "./lib";
import { getIronSession } from "iron-session";
export async function getSession() {
const useCookies = await cookies();
const session = await getIronSession<SessionData>(useCookies, sessionOptions);
if (!session.isLoggedIn) {
session.isLoggedIn = defaultSession.isLoggedIn;
session.username = defaultSession.username;
}
return session;
}See Strapi in action with an interactive demo
Setting up Navigation Pages and Layout
As mentioned earlier, our application will have multiple pages, and we will need a navbar to help users move between them. Some of these pages will only be accessible to authenticated users. To manage access, we will use the iron session we set up earlier to track user login status and protect links or pages accordingly.
Let's create a new file: src/app/components/navbar.tsx and add the code for the navigation:
"use client";
export default function NavBar({ isLoggedIn }: { isLoggedIn: boolean }) {
return (
<>
<nav className="relative px-4 py-4 flex justify-between items-center bg-white">
<a className="text-3xl font-bold leading-none" href="/">
Strapi 2FA
</a>
{isLoggedIn && <a href="/dashboard">Dashboard</a>}
{!isLoggedIn && (
<>
<a
className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200"
href="/auth/login"
>
Sign In
</a>
<a
className="hidden lg:inline-block py-2 px-6 bg-blue-500 hover:bg-blue-600 text-sm text-white font-bold rounded-xl transition duration-200"
href="/auth/register"
>
Sign up
</a>
</>
)}
</nav>
</>
);
}Next, we will navigate to the layout file: src/app/layout.tsx, import the NavBar component and the get getSession method from the actions file:
import NavBar from "./components/navbar";
import { getSession } from "./auth/actions";After that, we modify the RootLayout component:
...
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getSession();
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<NavBar isLoggedIn={session.isLoggedIn} />
{children}
</body>
</html>
);
}Implement User Registration
Let’s revisit the server actions file: src/app/auth/actions.ts and make some modifications to integrate the registration functionality with our Strapi backend.
First, import the redirect module from next/navigation:
import { redirect } from "next/navigation";Next, define the base API URL for our Strapi application before the getSession method:
const API_URL = "http://localhost:1337/api";Then, add the register server action to handle user registration:
export const register = async (prevState: any, formData: FormData) => {
const data = {
username: formData.get("username"),
email: formData.get("email"),
password: formData.get("password"),
};
const res = await fetch(`${API_URL}/auth/local/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok) {
return { message: json?.error?.message || "Something went wrong" };
}
redirect(`/auth/login`);
};The register function handles user registration by sending the username, email, and password from the FormData object through a POST request to the /auth/local/register endpoint of our Strapi app.
If the registration is successful, the user is redirected to the login page. In case of an error, it returns a message indicating what went wrong.
Lastly, let's create a new file, src/app/auth/register/page.tsx, to implement the UI for the registration page:
"use client";
import { useActionState } from "react";
import { register } from "../actions";
const initialState = {
message: "",
};
export default function RegisterPage() {
const [state, formAction] = useActionState(register, initialState);
return (
<>
<section className="">
<div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div className="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Sign up your account
</h1>
<form className="space-y-4 md:space-y-6" action={formAction}>
{state?.message && (
<p className="text-sm pt-3 text-red-500">{state.message}</p>
)}
<div>
<label
htmlFor="username"
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Username
</label>
<input
type="text"
name="username"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
placeholder="Username"
/>
</div>
<div>
<label
htmlFor="email"
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Email
</label>
<input
type="email"
name="email"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
placeholder="Email"
/>
</div>
<div>
<label
htmlFor="password"
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Password
</label>
<input
type="password"
name="password"
placeholder="••••••••"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
/>
</div>
<button
type="submit"
className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
Sign up
</button>
<p className="text-sm font-light text-gray-500 dark:text-gray-400">
Already have an account?{" "}
<a
href="/auth/login"
className="font-medium text-gray-600 hover:underline dark:text-gray-500"
>
Sign in
</a>
</p>
</form>
</div>
</div>
</div>
</section>
</>
);
}With the registration complete, we can now shift our focus to creating the user login.
Implement User Login and Logout
Using the same approach as in the user registration, we will update the server actions file src/app/auth/actions.ts to add the login and logout functionality.
Import the revalidatePath module from next/cache:
import { revalidatePath } from "next/cache";Here’s the code for the login and logout methods:
export const login = async (prevState: any, formData: FormData) => {
const data = {
identifier: formData.get("identifier"),
password: formData.get("password"),
};
const res = await fetch(`${API_URL}/auth/local`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok) {
return { message: json?.error?.message || "Something went wrong" };
}
redirect(`/auth/verify-code?e=${json.email}&vt=${json.verifyType}`);
};
export async function logout() {
const session = await getSession();
session.destroy();
revalidatePath("/dashboard");
}To implement the login page UI, create a new file: src/app/auth/login/page.tsx, and add the code:
"use client";
import { useActionState } from "react";
import { login } from "../actions";
const initialState = {
message: "",
};
export default function LoginPage() {
const [state, formAction] = useActionState(login, initialState);
return (
<>
<section className="">
<div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div className="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1 className="text-xl font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Sign in to your account
</h1>
<form className="space-y-4 md:space-y-6" action={formAction}>
{state?.message && (
<p className="text-sm pt-3 text-red-500">{state.message}</p>
)}
<div>
<label
htmlFor="identifier"
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Username or email
</label>
<input
type="text"
name="identifier"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
placeholder="Username or email"
/>
</div>
<div>
<label
htmlFor="password"
className="block mb-2 text-sm font-medium text-gray-900 dark:text-white"
>
Password
</label>
<input
type="password"
name="password"
placeholder="••••••••"
className="bg-gray-50 border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
/>
</div>
<button
type="submit"
className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center"
>
Sign in
</button>
<p className="text-sm font-light text-gray-500 dark:text-gray-400">
Don’t have an account yet?{" "}
<a
href="/auth/register"
className="font-medium text-gray-600 hover:underline dark:text-gray-500"
>
Sign up
</a>
</p>
</form>
</div>
</div>
</div>
</section>
</>
);
}Finally, let's update the navbar component src/app/components/navbar.tsx code to include the logout functionality:
"use client";
import { useActionState } from "react";
import { logout } from "../auth/actions";
export default function NavBar({ isLoggedIn }: { isLoggedIn: boolean }) {
const [state, formAction] = useActionState(logout, null);
return (
<>
<nav className="relative px-4 py-4 flex justify-between items-center bg-white">
<a className="text-3xl font-bold leading-none" href="/">
Strapi 2FA
</a>
{isLoggedIn && <a href="/dashboard">Dashboard</a>}
<div>
{!isLoggedIn && (
<>
<a
className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200"
href="/auth/login"
>
Sign In
</a>
<a
className="hidden lg:inline-block py-2 px-6 bg-blue-500 hover:bg-blue-600 text-sm text-white font-bold rounded-xl transition duration-200"
href="/auth/register"
>
Sign up
</a>
</>
)}
{isLoggedIn && (
<>
<form action={formAction}>
<button className="hidden lg:inline-block lg:ml-auto lg:mr-3 py-2 px-6 bg-gray-50 hover:bg-gray-100 text-sm text-gray-900 font-bold rounded-xl transition duration-200">
Logout
</button>
</form>
</>
)}
</div>
</nav>
</>
);
}The registration and login pages are now complete and functional. We can successfully register a new user and be redirected to the login page. However, if we log in, we won't be able to verify the OTP code sent to our email.
Next, we will work on email verification.
OTP Verification via Email
To handle OTP/TOTP verification on the frontend part, we will need to modify the server actions file: src/app/auth/actions.ts as usual.
Next, add the codes for the verifyCode function:
export const verifyCode = async (prevState: any, formData: FormData) => {
const data = {
email: formData.get("email"),
code: formData.get("code"),
type: formData.get("type"),
};
if (!data.code) return { message: "'code' is required" };
const res = await fetch(`${API_URL}/auth/verify-code`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok) {
return { message: json?.error?.message || "Something went wrong" };
}
const session = await getSession();
session.username = json.user.username;
session.isLoggedIn = true;
session.jwt = json.jwt;
await session.save();
revalidatePath("/dashboard");
redirect("/dashboard");
};The verifyCode function extracts the email, code, and type from the FormData and sends a POST request to the /auth/verify-code endpoint. If the request fails, an error message is returned; otherwise, the session is updated with the user's details. Finally, the function refreshes the dashboard path and redirects the user to /dashboard.
Now, let's add the UI for the verification page. Create a new file: src/app/auth/verify-code/page.tsx.
Here is the code for the page:
"use client";
import { useActionState } from "react";
import { verifyCode } from "../actions";
import { useSearchParams } from "next/navigation";
const initialState = {
message: "",
};
export default function VerifyCodePage() {
const searchParams = useSearchParams();
const email = searchParams.get("e") || "";
const verifyType = searchParams.get("vt") || "";
const typeTitle = verifyType === "otp" ? "OTP" : "Authenticator app";
const [state, useActionState] = useFormState(verifyCode, initialState);
return (
<>
<section className="">
<div className="flex flex-col items-center justify-center px-6 py-8 mx-auto md:h-screen lg:py-0">
<div className="w-full bg-white rounded-lg shadow dark:border md:mt-0 sm:max-w-md xl:p-0 dark:bg-gray-800 dark:border-gray-700">
<div className="p-6 space-y-4 md:space-y-6 sm:p-8">
<h1 className="text- text-center font-bold leading-tight tracking-tight text-gray-900 md:text-2xl dark:text-white">
Enter {typeTitle} Code
</h1>
<form className="space-y-4 md:space-y-6" action={formAction}>
{state?.message && (
<p className="text-sm text-center pt-1 text-red-500">
{state.message}
</p>
)}
<input type="hidden" name="email" value={email} />
<input type="hidden" name="type" value={verifyType} />
<div>
<input
type="text"
name="code"
autoComplete="off"
className="bg-gray-50 text-center border border-gray-300 text-gray-900 rounded-lg focus:ring-gray-600 focus:border-gray-600 block w-full p-2.5"
placeholder="Code"
/>
</div>
<button
type="submit"
className="w-full text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg px-5 py-2.5 text-center"
>
Verify
</button>
</form>
</div>
</div>
</div>
</section>
</>
);
}Let's create a new page for the dashboard: src/app/dashboard/page.tsx.
Here is the code for the page:
export default async function Page() {
return (
<>
<section>
<h2 className="py-8 text-3xl font-bold text-center">Dashboard</h2>
</section>
</>
);
}The dashboard page is currently minimal, containing only a heading. We will return to this page later to add the UI to set up an authenticator app.
At this stage, our application supports two-factor authentication (2FA), users can log in to using an OTP code sent to their email.
The verifyCode function manages OTP or TOTP verification on the frontend. By default, newly registered users start with email-based OTP verification until they configure an authenticator app for their account. I will work on that next.
Setup Authenticator App with QR Code
In this section, we will add the frontend functionality that allows users to set up an authenticator app for TOTP-based 2FA. This includes generating a QR code, which users can scan with an authenticator app like Google Authenticator or Microsoft Authenticator. Once scanned, the app will generate time-based one-time passwords that the user can use to log into their account.
Let’s revisit the src/app/auth/actions.ts file and add the required code to set up TOTP.
export async function generateSecret() {
const session = await getSession();
const res = await fetch(`${API_URL}/auth/generate-totp-secret`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.jwt}`,
},
});
const result = await res.json();
return result;
}
export async function saveTotpSecret(prevState: any, formData: FormData) {
const data = {
code: formData.get("code"),
secret: formData.get("secret"),
};
if (!data.code) return { message: "'code' is required" };
const session = await getSession();
const res = await fetch(`${API_URL}/auth/save-totp-secret`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.jwt}`,
},
body: JSON.stringify(data),
});
const json = await res.json();
if (!res.ok) {
return { message: json?.error?.message || "Something went wrong" };
}
redirect("/auth/app-setup-success");
}We add two new functions that handle generating and saving the TOTP secret for a user’s authenticator app setup.
The generateSecret function generates the TOTP secret needed to set up a user's authenticator app. The server response from this function includes the user's email, the generated secret, and a URL, which will be used to create the QR code later in this section.
The saveTotpSecret function saves the TOTP secret in the user's account details. When a TOTP secret is generated, we need to ensure that users have successfully saved it within their authenticator app. To verify this, we require users to submit the secret along with a TOTP code generated by the app.
After the user successfully sets up their authenticator app, they will be redirected to a success page.
Next, we'll create the necessary pages for the authenticator app setup.
To display the QR code, we need to install the react-qr-code package:
# Using Yarn
yarn add react-qr-code
# Using npm
npm install react-qr-codeNow, create a new file: src/app/auth/app-setup/Setup.tsx for the QR code setup component.
Here’s the code for the component:
"use client";
import { useActionState } from "react";
import { saveTotpSecret } from "../actions";
import QRCode from "react-qr-code";
const initialState = {
message: "",
};
export default function Setup({
secret,
url,
}: {
secret: string;
url: string;
}) {
const [state, formAction] = useActionState(saveTotpSecret, initialState);
return (
<>
<div className="">
<div
className="flex flex-col items-center px-6 py-8 mt-8
mx-auto lg:py-0 w-fit"
>
<div className="w-full bg-white rounded-lg shadow md:mt-0 sm:max-w-md xl:p-0">
<div className="p-6 space-y-2 md:space-y-3 sm:p-8">
<h3 className="text-xl font-semibold pb-4">
Setup authenticator app
</h3>
<p>
Use you a phone app like Microsoft Authenticator, etc, to scan
the QR Code, or enter the secret key manually.
</p>
<p className="py-2">
Secret key: <strong>{secret}</strong>
</p>
<div
style={{
height: "auto",
margin: "0 auto",
maxWidth: 150,
width: "100%",
}}
>
<QRCode
size={256}
style={{ height: "auto", maxWidth: "100%", width: "100%" }}
value={url}
viewBox={`0 0 256 256`}
/>
</div>
<div className="pt-4">
<p className="mb-2">
Enter the 6 digits code generated by the authenticator app and
click "Continue"
</p>
</div>
<div>
<p className="mb-3">
Verify the code from the authenticator app
</p>
<form action={formAction}>
<input type="hidden" name="secret" value={secret} />
<div>
<input
type="text"
name="code"
autoComplete="off"
className="bg-gray-50 border border-gray-300
text-gray-900 rounded-lg focus:ring-gray-600
focus:border-gray-600 block p-2"
placeholder="XXXXXX"
/>
</div>
{state.message && (
<div className="py-2 text-red-500 text-sm">
{state.message}
</div>
)}
<div className="pt-4">
<button
type="submit"
className="text-white bg-gray-600 hover:bg-gray-700 focus:ring-4 focus:outline-none focus:ring-gray-300 font-medium rounded-lg text-sm px-5 py-2 text-center"
>
Continue
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
</>
);
}Next, let's create a new file src/app/auth/app-setup/page.tsx for the authenticator app setup page and include the following code:
import { redirect } from "next/navigation";
import { generateSecret, getSession } from "../actions";
import Setup from "./Setup";
export default async function Page() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/auth/login");
const totpData = await generateSecret();
return (
<>
<Setup secret={totpData.secret} url={totpData.url} />
</>
);
}Finally, for the authenticator app setup, let's add the success page src/app/auth/app-setup-success/page.tsx:
export default function Page() {
return <>Authenticator app setup successful</>;
}To set up the authenticator app, users will need to navigate to the dashboard and click the setup link if TOTP is not yet enabled. Currently, the frontend lacks a way to indicate whether TOTP is enabled. Let’s address that by adding the checkTotpStatus function in the file src/app/auth/actions.ts:
export async function checkTotpStatus() {
const session = await getSession();
const res = await fetch(`${API_URL}/auth/totp-enabled`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.jwt}`,
},
});
const result: { enabled: boolean } = await res.json();
return result;
}Let us return to the dashboard page located at src/app/dashboard/page.tsx and make the necessary updates:
import { redirect } from "next/navigation";
import { getSession, checkTotpStatus } from "../auth/actions";
export default async function Page() {
const session = await getSession();
if (!session.isLoggedIn) redirect("/");
const totpEnabled = (await checkTotpStatus())?.enabled ?? false;
return (
<>
<section>
<h2 className="py-8 text-3xl font-bold text-center">Dashboard</h2>
<div className="text-center">
{!totpEnabled && (
<a className="text-lg font-semibold" href="/auth/app-setup">
Setup Authenticator App
</a>
)}
{totpEnabled && <p>Authenticator app enabled</p>}
</div>
</section>
</>
);
}Github Source Code
Conclusion
In this tutorial, we successfully implemented 2FA in a Strapi CMS application and integrated a frontend using Next.js. We explored both OTP-based email verification and TOTP-based authenticator app setup to provide users with flexible, secure login options.
We started with backend configurations, extended Strapi’s authentication logic to handle OTP and TOTP validation. On the frontend, we built user-friendly registration, login, and verification flows. We also walked through generating and saving TOTP secrets and used QR codes to simplify setting up an authenticator app.
Emeka is a skilled software developer and educator in web development, mentoring, and collaborating with teams to deliver business-driven solutions.