Part 8— Secure the front end Next.js app with next-auth

Loi Le
15 min readDec 28, 2023

--

This is a part of the Tutorial to build a blog platform with Elixir Phoenix and Next.js that help you to develop a web application from scratch using modern technologies such as Elixir Phoenix, Next.js, and more.

Index | < Pre | Next>

In the last part, we have implemented authentication for our create post api and added a get my posts api to fetch the posts created by the user. Now we need to improve our front end application. To do this, we need to enable user login and post management features. We will use next-auth for authentication on the front end, so let’s install the package:

yarn add next-auth

To create an API route in Next.js that handles all requests with the prefix /api/auth/*, we can create the file /src/app/api/auth/[…nextauth]/route.ts and implement the logic for authentication and authorization in this file:

import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

const handler = NextAuth({
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (!credentials || !credentials?.username || !credentials?.password) {
return null;
}

// 1. implement sign-in logic here
return null
},
}),
],
callbacks: {
async jwt({ token, account, user }) {
if (user) {
// 2. parse from sign in response to token
return null
}

if (new Date() < new Date(token.expiredAt)) {
return token;
}

try {
// 3. implement renew token here
return null
} catch (error) {
return {
...token,
error: "RefreshAccessTokenError" as const,
};
}
},
async session({ session, token }) {
// 4. parse from token to session
return null
},
},
});

export { handler as GET, handler as POST };

We can use the CredentialsProvider to authenticate the user with username and password. Then we will follow these steps in order:

  1. Write the sign in logic in the authorize function and return the sign in response.
  2. Convert the sign in response to token information in the jwt function.
  3. Renew the token if it is expired and return the new token information.
  4. Convert the token information to session information in the session function so that we can access it with the useSession hook.

Before we do that, we need some helper functions for the account feature. We will put them in the /src/models/accounts/ folder. Let’s start by defining some types in /src/models/accounts/types.ts

export interface LoginResponse {
id: string;
access_token: string;
expired_at: string;
renewal_token: string;
user: {
email: string;
};
}

export interface TokenInfo {
expiredAt: string;
accessToken: string;
refreshToken: string;
user: {
email: string;
};
error?: string;
}

export interface SessionInfo {
accessToken: string;
user: {
email: string;
};
error?: string;
}

Next, we need to create /next-auth.d.ts file to add our types to the next-auth types.

import {
LoginResponse,
SessionInfo,
TokenInfo,
} from "@/app/models/accounts/types";
import NextAuth from "next-auth";
import { JWT } from "next-auth/jwt";

declare module "next-auth" {
interface Session extends SessionInfo {}

interface User extends LoginResponse {}
}

declare module "next-auth/jwt" {
interface JWT extends TokenInfo {}
}

We need to add our types to the /tsconfig.json file

{
...
"include": ["next-env.d.ts", "next-auth.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

Next, we need to create the signIn function in /src/models/accounts/signIn.ts

import axios from "@/app/axios";
import { LoginResponse } from "./types";

const signIn = async (params: { email: string; password: string }) => {
const { email, password } = params;
const response = await axios.post<{
data: LoginResponse;
}>(`${process.env.API_URL}/api/session`, {
user: {
email: email,
password: password,
},
});

return response.data?.data;
};

export default signIn;

Our signIn function will use the API_URL environment variable to call our api, so we need to set it in the .env.local file:

NEXT_PUBLIC_API_URL=http://localhost:4000
API_URL=http://localhost:4000

The NEXT_PUBLIC_API_URL is for the client side and the API_URL is for the server side, where our authentication logic is.

Next, we need to write the refreshToken function in /src/models/accounts/refreshToken.ts to renew the token if it is expired

import axios from "@/app/axios";
import { LoginResponse } from "./types";

const refreshToken = async (params: { token: string }) => {
const { token } = params;
const response = await axios.post<{
data: LoginResponse;
}>(
`${process.env.API_URL}/api/session/renew`,
{},
{ headers: { Authorization: token } }
);

return response.data?.data;
};

export default refreshToken;

Next, we need to write the toToken function in /src/models/accounts/toToken.ts to convert the login response to a JWT object.

import { JWT } from "next-auth/jwt";
import { LoginResponse } from "./types";

const toToken = (loginResponse: LoginResponse): JWT => {
return {
accessToken: loginResponse.access_token,
expiredAt: loginResponse.expired_at,
refreshToken: loginResponse.renewal_token,
user: loginResponse.user,
};
};

export default toToken;

Then we create the toSession function in /src/models/accounts/toSession.ts

import { Session } from "next-auth";
import { JWT } from "next-auth/jwt";

const toSession = (
token: JWT,
defaultSession?: Session
): Session => {
return {
...(defaultSession || {}),
error: token.error,
user: {
...(defaultSession?.user || {}),
...token.user,
},
accessToken: token.accessToken,
expires: defaultSession?.expires || "",
};
};

export default toSession

We need to create an index file in /src/models/accounts/index.ts to export all the helper functions that we have written for the account feature.

export { default as signIn } from "./signIn";
export { default as refreshToken } from "./refreshToken";
export { default as toToken } from "./toToken";
export { default as toSession } from "./toSession";

With these helper functions, we can finish the 4 steps in /src/app/api/auth/[…nextauth]/route.ts to implement authentication and authorization

import {
refreshToken,
signIn,
toSession,
toToken,
} from "@/app/models/accounts";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";

const handler = NextAuth({
secret: process.env.AUTH_SECRET,
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
if (!credentials || !credentials?.username || !credentials?.password) {
return null;
}

return signIn({
email: credentials?.username,
password: credentials?.password,
});
},
}),
],
callbacks: {
async jwt({ token, account, user }) {
if (user) {
return toToken(user);
}

if (new Date() < new Date(token.expiredAt)) {
return token;
}

try {
const response = await refreshToken({
token: token.refreshToken,
});

return toToken(response);
} catch (error) {
return {
...token,
error: "RefreshAccessTokenError" as const,
};
}
},
async session({ session, token }) {
return toSession(token, session);
},
},
});

export { handler as GET, handler as POST };

We have completed the next-auth configuration. Now, to access the next-auth session, we need to wrap the SessionProvider in a react Context and use it in the layout src/app/layout.tsx

First, we have to create src/app/contexts/AuthContext.tsx

"use client";

import { SessionProvider } from "next-auth/react";

export interface AuthContextProps {
children: React.ReactNode;
}

export default function AuthContext({ children }: AuthContextProps) {

return <SessionProvider>{children}</SessionProvider>;
}

Then update the src/app/layout.tsx

import Toast from "./components/atoms/Toast";
import QueryContext from "./contexts/QueryContext";
import "./globals.css";
import { Inter } from "next/font/google";
import AuthContext from "./contexts/AuthContext";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Toast />
<AuthContext>
<QueryContext>
<div className="min-h-full">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex items-center">
<div className=" text-lg md:text-3xl font-semibold">
Lani Blog
</div>
</div>
</div>
</div>
</header>
{children}
</div>
</QueryContext>
</AuthContext>
</body>
</html>
);
}

We want to restrict access to our create post page, so we need to modify /src/app/create-post/page.tsx

"use client";
import { signIn, useSession } from "next-auth/react";

import useCreatePost, { CreatePostParams } from "@/app/hooks/useCreatePost";
import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form";
import { toast } from "react-toastify";
import * as yup from "yup";
import { yupResolver } from "@hookform/resolvers/yup";
import FormInput from "../components/form/FormInput";
import FormMarkDownInput from "../components/form/FormMarkDownInput";
import Button from "../components/atoms/Button";
import { useEffect } from "react";
import Loading from "../components/atoms/Loading";

const CreatePost: React.FC = () => {
const { status } = useSession();

const router = useRouter();
const schema = yup.object().shape({
title: yup.string().nullable().required("Please input title"),
description: yup.string().nullable().required("Please input description"),
content: yup.string().nullable().required("Please input content"),
});

const {
control,
handleSubmit,
formState: { errors },
} = useForm<CreatePostParams>({
resolver: yupResolver(schema),
});

const { mutate: createPost, isLoading: isSubmitting } = useCreatePost(
(data) => {
toast.success("Post created successfully");
data?.id && router.push(`/posts/${data.id}`);
},
() => {
toast.error("Something went wrong while creating post");
}
);

useEffect(() => {
if (status === "unauthenticated") {
signIn();
}
}, [status]);

const onSubmit = (values: CreatePostParams) =>
createPost({
...values,
});

if (status === "loading")
return (
<div className="my-24">
<Loading />
</div>
);

if (status !== "authenticated") return null;

return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-7xl">
<h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Create Post
</h2>
</div>
<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-7xl px-8">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<FormInput
label="Title"
name="title"
containerClassName="max-w-md mx-auto"
control={control}
error={errors.title}
/>
<FormInput
containerClassName="max-w-md mx-auto"
label="Description"
name="description"
control={control}
error={errors.description}
/>
<FormMarkDownInput
label={"Content"}
control={control}
name="content"
error={errors.content}
/>
<div className="w-full flex items-center justify-center space-x-2">
<Button
className="!w-32"
variant="white"
onClick={() => router.back()}
>
Back
</Button>
<Button className="!w-32" type="submit" loading={isSubmitting}>
Create Post
</Button>
</div>
</form>
</div>
</div>
);
};

export default CreatePost;

We will use the useSession hook from next-auth to check the session status. If the user is not authenticated, we will use the signIn function from next-auth to redirect them to the sign-in page. Because, you have not logged in yet, so if you try to access http://localhost:3000/create-post, you will be redirected to the sign-in page:

To access our application, we need to create (sign-up) a new account. Let’s start by writing the hook for the sign-up page /src/app/hooks/useSignUp.ts.

import { useMutation } from "react-query";
import axios from "../axios";

export interface SignUpParams {
username: string;
password: string;
confirmPassword: string;
}

const onMutate = async (params: SignUpParams) => {
const { username, password, confirmPassword } = params;
await axios.post("/api/registration", {
user: {
email: username,
password: password,
password_confirmation: confirmPassword,
},
});
};

const useSignUp = (
onSuccess?: (data: void, variables: SignUpParams, context: any) => void,
onError?: (error: any, variables: SignUpParams, context: any) => void
) => {
return useMutation(onMutate, {
onSuccess,
onError,
});
};

export default useSignUp;

Like other hooks, we just define the parameters and send them to the /api/registration api to sign up

Then, let’s write the sign up page in /src/app/sign-up/page.tsx

"use client";

import Button from "../components/atoms/Button";
import * as yup from "yup";
import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import FormInput from "../components/form/FormInput";
import { signIn } from "next-auth/react";
import useSignUp, { SignUpParams } from "../hooks/useSignUp";
import { toast } from "react-toastify";

const SignUp: React.FC = () => {
const schema = yup.object().shape({
username: yup.string().nullable().required("Please input username"),
password: yup.string().nullable().required("Please input password"),
confirmPassword: yup
.string()
.nullable()
.oneOf([yup.ref("password"), null], "Passwords must match")
.required("Please input confirm password"),
});

const {
control,
handleSubmit,
formState: { errors },
} = useForm<SignUpParams>({
resolver: yupResolver(schema),
});

const { mutate: signUp, isLoading } = useSignUp(
() => {
signIn(undefined, { callbackUrl: "/" });
toast.success("Sign up successfully");
},
() => {
toast.error("Something went wrong while signing up");
}
);

const onSubmit = (values: SignUpParams) => signUp(values);

return (
<div className="flex min-h-full flex-1 flex-col justify-center px-6 py-12 lg:px-8">
<div className="sm:mx-auto sm:w-full sm:max-w-sm">
<h2 className="mt-10 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Sign in to your account
</h2>
</div>

<div className="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
<form className="space-y-6" onSubmit={handleSubmit(onSubmit)}>
<FormInput
label="User Name"
name="username"
control={control}
error={errors.username}
/>
<FormInput
label="Password"
name="password"
type="password"
control={control}
error={errors.password}
/>
<FormInput
label="Confirm Password"
name="confirmPassword"
type="password"
control={control}
error={errors.confirmPassword}
/>
<div>
<Button type="submit" className="!w-full" loading={isLoading}>
Sign Up
</Button>
</div>
</form>
</div>
</div>
);
};

export default SignUp;

We have already learned how to create a form. We used yup to set the validation rules and used react-hook-form to register them. We used the FormInput components that we created earlier to build the UI. When the user submits the form, we used the useSignUp hook that we wrote to send the api request.

Then, we need to add a way for the user to go to our sign-up page. A common way is to show the sign-in/ sign-up buttons in the header when the user is not logged in and show the sign out option when the user is logged in. Let’s look at our layout /src/app/layout.tsx

import Toast from "./components/atoms/Toast";
import AuthContext from "./contexts/AuthContext";
import QueryContext from "./contexts/QueryContext";
import "./globals.css";
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Toast />
<AuthContext>
<QueryContext>
<div className="min-h-full">
<header className="border-b border-gray-200 bg-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex items-center">
<div className=" text-lg md:text-3xl font-semibold">
Lani Blog
</div>
</div>
</div>
</div>
</header>
{children}
</div>
</QueryContext>
</AuthContext>
</body>
</html>
);
}

Our header is very basic right now, it only shows the Blog Title. We want to add more functionality here, such as showing the sign-in/sign-up buttons when the user is not logged in and showing the user menu with the logout option when the user is logged in. We will use headlessui to create the menu, so let’s install it:

yarn add @headlessui/react

Then we can create the header component in /src/app/components/atoms/Header/index.tsx

"use client"

import { Fragment } from "react";
import { Disclosure, Menu, Transition } from "@headlessui/react";
import {
Bars3Icon,
ChevronDownIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import classNames from "classnames";
import Button from "@/app/components/atoms/Button";
import { signIn, signOut, useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Loading from "@/app/components/atoms/Loading";

const Header: React.FC = () => {
const { status, data: session } = useSession();
const router = useRouter();

const userEmail = session?.user?.email;

const userNavigation = [
{
name: "Sign out",
onClick: () => signOut(),
},
];

const unAuthorizeNavigation = [
{
name: "Sign In",
variant: "primary" as "primary" | "white",
onClick: () => signIn(),
},
{
name: "Sign Up",
variant: "white" as "primary" | "white",
onClick: () => router.push("/sign-up"),
},
];

return (
<Disclosure as="header" className="border-b border-gray-200 bg-white">
{({ open }) => (
<>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 justify-between">
<div className="flex">
<Link href="/" className="flex items-center">
<div className=" text-lg md:text-3xl font-semibold">
Lani Blog
</div>
</Link>
</div>
<div className="hidden sm:ml-6 sm:flex sm:items-center space-x-2">
{status === "loading" && <Loading />}
{status === "unauthenticated" &&
unAuthorizeNavigation.map((nav) => (
<Button
key={nav.name}
variant={nav.variant}
onClick={nav.onClick}
>
{nav.name}
</Button>
))}
{status === "authenticated" && (
<>
<Menu as="div" className="relative ml-3">
<div>
<Menu.Button className="flex max-w-xs items-center rounded-full bg-white text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 space-x-2">
<div className="text-lg">{userEmail}</div>
<ChevronDownIcon className="h-4 w-4" />
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 z-10 mt-2 w-48 origin-top-right rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
{userNavigation.map((item) => (
<Menu.Item key={item.name}>
{({ active }) => (
<button
onClick={item.onClick}
className={classNames(
active ? "bg-gray-100" : "",
"w-full text-right block px-4 py-2 text-sm text-gray-700"
)}
>
{item.name}
</button>
)}
</Menu.Item>
))}
</Menu.Items>
</Transition>
</Menu>
</>
)}
</div>
<div className="-mr-2 flex items-center sm:hidden">
<Disclosure.Button className="inline-flex items-center justify-center rounded-md bg-white p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
<span className="sr-only">Open main menu</span>
{open ? (
<XMarkIcon className="block h-6 w-6" aria-hidden="true" />
) : (
<Bars3Icon className="block h-6 w-6" aria-hidden="true" />
)}
</Disclosure.Button>
</div>
</div>
</div>

<Disclosure.Panel className="sm:hidden">
<div className="border-t border-gray-200 pb-3 pt-4">
{status === "loading" && <Loading />}
{status === "unauthenticated" && (
<div className="space-y-1">
{unAuthorizeNavigation.map((nav) => (
<Disclosure.Button
key={nav.name}
as="button"
onClick={nav.onClick}
className="w-full text-left block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800"
>
{nav.name}
</Disclosure.Button>
))}
</div>
)}
{status === "authenticated" && (
<>
<div className="px-4 text-lg">{userEmail}</div>
<div className="mt-3 space-y-1">
{userNavigation.map((item) => (
<Disclosure.Button
key={item.name}
as="button"
onClick={item.onClick}
className="w-full text-left block px-4 py-2 text-base font-medium text-gray-500 hover:bg-gray-100 hover:text-gray-800"
>
{item.name}
</Disclosure.Button>
))}
</div>
</>
)}
</div>
</Disclosure.Panel>
</>
)}
</Disclosure>
);
};

export default Header;

The logic behind this is simple, even though it may seem complex at first. We start by using the useSession hook to check the session status. Then we create two sets of menu items: one for authenticated users and one for unauthenticated users. Depending on the session status, we display the appropriate menu. Our UI also supports responsive design. To use the Header component, we need to update our layout file /src/app/layout.tsx.

import AuthContext from "./contexts/AuthContext";
import Header from "./components/atoms/Header";
import Toast from "./components/atoms/Toast";
import QueryContext from "./contexts/QueryContext";
import "./globals.css";
import { Inter } from "next/font/google";

const inter = Inter({ subsets: ["latin"] });

export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};

export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<Toast />
<AuthContext>
<QueryContext>
<div className="min-h-full">
<Header />
{children}
</div>
</QueryContext>
</AuthContext>
</body>
</html>
);
}

To see the result, we can visit http://localhost:3000

After clicking the Sign Up button, we will be redirected to http://localhost:3000/sign-up

To create a new account, we need to enter the required information and click submit.

If the sign-up is successful, we will be taken to the sign-in page http://localhost:3000/api/auth/signin?callbackUrl=%2F

To log in to our application, we need to use the account we just created.

The header will show the username and the menu will have a log out option.

By clicking the Create button, we can access the page for creating a new post http://localhost:3000/create-post

To create a new post, we need to enter and submit our data.

However, we will encounter an error because we have not changed the create post url to match the server.

We need to add our access_token to the http request for authentication. We can fix this by modifying the hook file /src/app/hooks/useCreatePost.ts

import { useMutation } from "react-query";
import axios from "../axios";
import { Post, PostDto } from "../models/posts/types";
import { toModel } from "../models/posts";

export type CreatePostParams = Pick<
Post,
"title" | "description" | "content"
> & {
accessToken?: string;
};

const onMutate = async (params: CreatePostParams) => {
const { title, description, content, accessToken } = params;

const response = await axios.post<{
data: PostDto;
}>(
`/api/my-posts`,
{
data: {
title,
description,
content,
},
},
{
headers: {
Authorization: accessToken,
},
}
);

return toModel(response.data?.data);
};

const useCreatePost = (
onSuccess?: (
data: Post | null | undefined,
variables: CreatePostParams,
context: any
) => void,
onError?: (error: any, variables: CreatePostParams, context: any) => void
) => {
return useMutation(onMutate, {
onSuccess,
onError,
});
};

export default useCreatePost;

The url was updated and the access_token is added to the params list and used as the Authorization header for the api request. Then we use the useSession hook to get the session and modify the onSubmit function to pass the accessToken parameter with the session’s accessToken in the /src/app/create-post/page.tsx file.

const { data: session, status } = useSession();
...
const onSubmit = (values: CreatePostParams) =>
createPost({
...values,
accessToken: session?.accessToken,
});

Now we try to create post again and it should be successful now.

We have accomplished a difficult task that involved a lot of work and setup. I hope you have learned something and understood how to implement authentication on the front end side. In the next part, we will create the My Posts screen so that user can view their own posts.

Index | < Pre | Next>

--

--