- Published on
Insta-Next: Authentication with NextAuth
- Authors
- Name
- Hoh Shen Yien
Table of Contents
In this part, we will be building the authentication part of the web application using NextAuth.js.
Particularly, we will look into the process of login and registration, as well as retrieving the active user information from the backend. Finally, we will also secure the API routes with Middleware.
If you've missed the previous parts, you can continue from where we left off the last part.
Sneak Peek:
NextAuth
Unsurprisingly, NextAuth is the authentication for Next.js.
Unlike the traditional SPAs which needs to maintain session tokens manually on the frontend, Next.js abstracts all these processes, so all we need to do is defining the configurations and it'll be ready to go!
Some other benefits of NextAuth:
- To login and logout, we simply call a function
signIn
orsignOut
on the client (frontend). - The client can easily get the user's information from the token using
getSession
. - The server can retrieve the active user's information using
getServerSession
. - Can integrate a range of third-party providers like Google easily (no need the usual extra libraries)
- Integrate well with libraries like
Prisma
I hope that's enough to convince you, regardless, this tutorial is going to use NextAuth.
Setting Up
Most of these are taken directly from NextAuth's documentation
Dependency
As usual, let's just install the node package
yarn add next-auth
Database Preparation
However, before we can proceed, we must make some changes to our database schema to fulfill NextAuth's requirements. By default, NextAuth requires the IDs to be in String instead of a number that we're currently implementing. Besides, they also need another Model called Session to store session info.
Since we're changing, we might as well use String for the Post and Story to keep them consistent
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
username String @unique
email String @unique
password String
description String @default("")
created_at DateTime @default(now())
followers UserFollower[] @relation("followers")
followings UserFollower[] @relation("followings")
posts_liked PostLike[]
posts Post[]
stories Story[]
Session Session[]
}
model Post {
id String @id @default(cuid())
caption String
user_id String
user User @relation(fields: [user_id], references: [id])
liked_bys PostLike[]
created_at DateTime @default(now())
}
model Story {
id String @id @default(cuid())
caption String
user_id String
user User @relation(fields: [user_id], references: [id])
created_at DateTime @default(now())
}
model Image {
id Int @id @default(autoincrement())
type String
url String
associated_id String
sequence Int
}
model UserFollower {
user_id String
user User @relation("followers", fields: [user_id], references: [id])
follower_id String
follower User @relation("followings", fields: [follower_id], references: [id])
created_at DateTime @default(now())
@@id([user_id, follower_id])
}
model PostLike {
user_id String
user User @relation(fields: [user_id], references: [id])
post_id String
post Post @relation(fields: [post_id], references: [id])
created_at DateTime @default(now())
@@id([user_id, post_id])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
user_id String
expires DateTime
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
Then, let's run the migration again
yarn migrate
PS: There'll probably be a warning about resetting the database, just enter yes, but we will seed the database later, not now
You might also want to update the types for these files
attach-image.ts
findSinglePost.ts
[post_id]/index.ts
[post_id]/likeds.ts
findPostLikedUsers
modals/types.ts
api/posts.ts
posts/Liked/PostLiked.tsx
Nothing of significant concern here, just some type changes.
NextAuth Configuration
To use NextAuth properly, we will need to set another environment variable, NEXTAUTH_SECRET
as a secret key for generating JWT tokens, you can use this UUID generator to create a unique secret for yourself, but here's mine
# .env
DATABASE_URL="postgresql://postgres:secret@localhost:5432/instanext"
NEXTAUTH_SECRET=4411c946-fa73-481a-8c54-f0df76105a57
All auth APIs will be handled by src/pages/api/auth/[...nextauth].ts
, which is also where all the NextAuth configurations lie. Let's just copy the example given from the documentation
// src/pages/api/auth/[...nextauth].ts
import NextAuth from 'next-auth'
import GithubProvider from 'next-auth/providers/github'
export const authOptions = {
// Configure one or more authentication providers
providers: [
GithubProvider({
clientId: process.env.GITHUB_ID,
clientSecret: process.env.GITHUB_SECRET,
}),
// ...add more providers here
],
}
export default NextAuth(authOptions)
Prisma Adapter
Next, we must make sure NextAuth can read the user's info from our database. To achieve this, we will rely on NextAuth's Prisma Adapter
yarn add @next-auth/prisma-adapter
And we can proceed to add Prisma Adapter to our configuration, I also deleted the GitHubProvider
for now
// src/pages/api/auth/[...nextauth].ts
import prisma from "@/utils/prisma";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
...
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma),
}
Certainly, we aren't planning to use GitHub as our provider now. Instead, we will look at Credential Authentication. Copying over the codes, and updating to use Prisma in our implementation,
export const authOptions: AuthOptions = {
adapter: PrismaAdapter(prisma),
// JWT for credentials
session: {
strategy: 'jwt',
},
// Configure one or more authentication providers
providers: [
CredentialsProvider({
name: 'Credentials',
credentials: {
email: { label: 'Email', type: 'email' },
password: { label: 'Password', type: 'password' },
},
async authorize(credentials, req) {
const user = await prisma.user.findFirstOrThrow({
where: {
email: credentials?.email,
},
})
const userWithImage = await attachImage(user, 'user')
if (user) {
return {
id: user.id,
email: user.email,
name: user.username,
image: userWithImage.profile_pic?.url,
}
} else {
return null
}
},
}),
],
}
Password Encryption
Almost there, we still need one last thing, password hashing. Storing passwords in plaintext is the last thing you'd want to do.
We will use the famous bcrypt for this
yarn add bcrypt
yarn add -D @types/bcrypt # to get the type declaration
Let's add some password features
// src/features/password/hashPassword.ts
import { hash } from 'bcrypt'
const hashPassword = async (password: string) => {
return await hash(password, 12)
}
export default hashPassword
// src/features/password/comparePassword .ts
import { compare } from 'bcrypt'
const comparePassword = async (plainTextPassword: string, hashedPassword: string) => {
return await compare(plainTextPassword, hashedPassword)
}
export default comparePassword
We can now check the password properly!
// src/pages/api/auth/[...nextauth].ts
...
const user = await prisma.user.findFirstOrThrow({
where: {
email: credentials?.email,
},
});
const isValid = await comparePassword(
credentials?.password ?? "",
user.password
);
const userWithImage = await attachImage(user, "user");
if (user && isValid) {
// these are the data that the frontend can acquire
return {
id: user.id,
email: user.email,
name: user.username,
image: userWithImage.profile_pic?.url,
};
} else {
return null;
}
Finally, let's update our user factory to use hashed password instead,
// prisma/factories/user.ts
import { faker } from '@faker-js/faker'
import { Prisma } from '@prisma/client'
import { hashSync } from 'bcrypt'
export const fakeUser = (): Prisma.UserCreateInput => ({
username: faker.name.firstName() + faker.name.lastName(),
email: faker.internet.email(),
// using hashSync here so that this function doesn't become async
password: hashSync('secret', 12),
description: faker.lorem.paragraph(),
})
We can now seed our database
yarn seed
Client Side
There's only one part where we need to setup the client side, _app.tsx
to wrap our components in a SessionProvider
to allow us to use useSession
in retrieving the active user information
// src/pages/_app.tsx
...
export default function App({ Component, pageProps }: AppPropsWithLayout) {
const [queryClient] = React.useState(() => new QueryClient());
const getLayout = Component.getLayout || getDefaultLayout;
return (
<>
<Head>
<title>Page title</title>
<meta
name="viewport"
content="minimum-scale=1, initial-scale=1, width=device-width"
/>
</Head>
<SessionProvider>
<QueryClientProvider client={queryClient}>
<Hydrate state={pageProps.dehydratedState}>
<MantineProvider
withGlobalStyles
withNormalizeCSS
theme={{
colorScheme: "light",
}}
>
<ModalsProvider modals={modals}>
{getLayout(<Component {...pageProps} />)}
</ModalsProvider>
</MantineProvider>
</Hydrate>
</QueryClientProvider>
</SessionProvider>
</>
);
}
and we are done!
Testing
Let's test if everything is working okay. Open up our pages/index.tsx
, and add some arbitrary buttons for testing purposes. Remember to update to use email from your own database
import { signIn, useSession } from 'next-auth/react'
export default function Home() {
const posts = useQuery({ queryFn: getAllPosts, queryKey: ['all-posts'] })
const session = useSession()
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex flex-col items-center space-y-4">
{/* Here's the test sign in function */}
<button
onClick={() =>
signIn('credentials', {
email: 'Ila25@hotmail.com',
password: 'secret',
})
}
>
Sign in
</button>
{/* Here's to check the current active session, remember to delete it later */}
<button onClick={() => console.log(session)}>Current session</button>
<StoryCarousel />
{posts.isSuccess && posts.data.posts.map((post, index) => <Post post={post} key={index} />)}
</div>
</>
)
}
Clicking on Current Session should print status "unauthenticated"
Let's click Sign In, and check Current Session again, you should be able to see updated status and the active user object.
Route Protection
Next, let's try to protect our pages with authentication. That is, we will prevent unauthenticated users from browsing certain pages.
How do we do that? There are usually many ways, but my preferred way in Next.js is to set explicitly if the PageComponent is public, and create an AuthGuard
which shows the LoginForm if unauthenticated.
So let's first update the type declaration of NextPageWithLayout
in _app.tsx
to include an attribute isPublic
export type NextPageWithLayout<P = {}, IP = P> = NextPage<P, IP> & {
getLayout?: (page: ReactElement) => ReactNode
} & {
isPublic?: boolean
}
Then, we will create an AuthGuard which checks the page's isPublic
// src/components/auth/AuthGuard.tsx
import { NextPageWithLayout } from '@/pages/_app'
import { useSession } from 'next-auth/react'
import getDefaultLayout from '../layouts/DefaultLayout'
interface AuthGuardProps {
Component: NextPageWithLayout
pageProps: any
}
const AuthGuard = ({ Component, pageProps }: AuthGuardProps) => {
const session = useSession()
// Notice that I've moded the getLayout from _app.tsx to here
const getLayout = Component.getLayout || getDefaultLayout
const isAuthenticated = Component.isPublic || session.status == 'authenticated'
return isAuthenticated ? getLayout(<Component {...pageProps} />) : <>Login Form</>
}
export default AuthGuard
And using it in our _app.tsx
// src/pages/_app.tsx
...
<ModalsProvider modals={modals}>
<AuthGuard Component={Component} pageProps={pageProps} />
</ModalsProvider>
...
Is it working? Well we can open incognito browser and check it out, and you should see the text "Login Form" showing!
Splash Screen
So far everything is great, however, if you head back to the authenticated browser and reload the page, you'd notice a short flash of "Login Form" before it shows the main page.
This is because NextAuth needs to make a request to backend to determine the current active user, which is the third status of session, isLoading
. To make the flow smoother, we can show a simple splash screen like Instagram's while loading
Here's the logo.svg for you, remember to put it in the /public
folder. I did some tailwind magic to get the gradient text at the bottom there,
import Image from 'next/image'
const SplashScreen = () => {
return (
<div className="relative flex h-screen w-screen flex-col items-center justify-center">
<Image src="/logo.svg" alt="InstaNext" height={120} width={120} />
<div className="absolute bottom-4 text-center">
<p className="text-sm font-semibold text-gray-600">from</p>
<p className="bg-gradient-to-br from-[#FF8121] via-[#FF4507] via-20% to-[#E341CC] to-70% bg-clip-text text-lg font-semibold text-transparent">
Shen Yien
</p>
</div>
</div>
)
}
export default SplashScreen
And here's how it'll look
Let's put everything together in our AuthGuard
const AuthGuard = ({ Component, pageProps }: AuthGuardProps) => {
const session = useSession()
const getLayout = Component.getLayout || getDefaultLayout
// The session might go loading again, so check both data and status
const isAuthenticated = session.status == 'authenticated' || session.data != null
const canBrowse = Component.isPublic || isAuthenticated
// This is so that splash screen is only show once
const [showSplash, setShowSplash] = useState(true)
useEffect(() => {
if (session.status != 'loading') {
setShowSplash(false)
}
}, [session, showSplash])
if (showSplash) {
return <SplashScreen />
}
return canBrowse ? getLayout(<Component {...pageProps} />) : <>Login Form</>
}
Login Form
This is Instagram's Login Form, let's try to copy over
While Instagram built their own mobile phone with phone frames & changing images, I'll just use a simple screenshot I took of the phones and add in the form, download them here
// src/components/auth/LoginForm.tsx
import { Button, Image, PasswordInput, TextInput } from '@mantine/core'
import Link from 'next/link'
const LoginForm = () => {
return (
<div className="pt-12">
<div className="mx-auto grid max-w-[800px] grid-cols-2">
<div className="flex items-center justify-center">
<Image alt="InstaNext Promo Picture" src="/login-phone.png" width={420} height="auto" />
</div>
<div className="flex items-center justify-center">
<div className="min-w-[340px]">
<form className="border-2 border-solid border-gray-200 px-10 py-12">
<Link href="/">
<Image src="/brand.svg" alt="InstaNext" width="200" className="mx-auto" />
</Link>
<div className="mt-10 space-y-1 ">
<TextInput placeholder="Email" />
<PasswordInput placeholder="Password" />
</div>
<Button fullWidth className="mt-2 rounded-md bg-blue-400 hover:bg-blue-500">
Log in
</Button>
</form>
<div className="mt-3 border-2 border-solid border-gray-200 py-4 text-center">
Don{"'"}t have an account?{' '}
<Link href="/auth/sign-up" className="font-semibold text-blue-400">
Sign up
</Link>
</div>
</div>
</div>
</div>
<div className="mt-12 text-center text-sm text-gray-400">
InstaNext - Instagram Clone by{' '}
<Link href="https://shenyien.cyou" target="_blank" className="text-blue-400">
Shen Yien
</Link>
</div>
</div>
)
}
export default LoginForm
And plugging in the LoginForm
into our AuthGuard
...
return canBrowse ? getLayout(<Component {...pageProps} />) : <LoginForm />;
...
And now, entering the page without being authenticated, it looks nice!
However, notice that the page doesn't do much now? Let's add some form logics, you can use Mantine's useForm for it.
We will make the button type submit
, and for the form's onSubmit, we let Mantine's useForm
handle it, and pass the values to NextAUth's signIn
import { Button, Image, PasswordInput, TextInput } from '@mantine/core'
import { useForm } from '@mantine/form'
import { signIn } from 'next-auth/react'
import Link from 'next/link'
const LoginForm = () => {
const form = useForm<{ email: string; password: string }>()
return (
<div className="pt-12">
<div className="mx-auto grid max-w-[800px] grid-cols-2">
<div className="flex items-center justify-center">
<Image alt="InstaNext Promo Picture" src="/login-phone.png" width={420} height="auto" />
</div>
<div className="flex items-center justify-center">
<div className="min-w-[340px]">
<form
className="border-2 border-solid border-gray-200 px-10 py-12"
onSubmit={form.onSubmit((values) => signIn('credentials', values))}
>
<Link href="/">
<Image src="/brand.svg" alt="InstaNext" width="200" className="mx-auto" />
</Link>
<div className="mt-10 space-y-1 ">
<TextInput placeholder="Email" {...form.getInputProps('email')} />
<PasswordInput placeholder="Password" {...form.getInputProps('password')} />
</div>
<Button
fullWidth
className="mt-2 rounded-md bg-blue-400 hover:bg-blue-500"
type="submit"
>
Log in
</Button>
</form>
<div className="mt-3 border-2 border-solid border-gray-200 py-4 text-center">
Don{"'"}t have an account?{' '}
<Link href="/auth/sign-up" className="font-semibold text-blue-400">
Sign up
</Link>
</div>
</div>
</div>
</div>
<div className="mt-12 text-center text-sm text-gray-400">
InstaNext - Instagram Clone by{' '}
<Link href="https://shenyien.cyou" target="_blank" className="text-blue-400">
Shen Yien
</Link>
</div>
</div>
)
}
export default LoginForm
You can test signing in now, it will work!
Updating Backend Logic
Finally, we can do some minor updates to our logic for /api/posts
and /api/stories
so that only those that the users are following will show.
Retrieving Active User
NextAuth provides a convenient getServerSession to retrieve current active user. However, the session object does not contain the current user's id, which is a little annoying to work with. Let's update our src/pages/api/auth[...nextauth].ts
to include it!
// src/pages/api/auth[...nextauth].ts
export const authOptions: AuthOptions = {
...
callbacks: {
session: async ({ session, token }) => {
if (session?.user) {
// no idea why jwt token stores the user id in an attribute called sub
session.user.id = token.sub ?? "";
}
return session;
},
},
}
At this point, the IDE is probably showing an error that id does not exist in User
object, so to correct the type system, you can create a type declaration file at the root
// next-auth.d.ts
import 'next-auth'
declare module 'next-auth' {
interface User {
id: string
}
interface Session {
user: User
}
}
And it's fixed!
Posts
For posts, we can simply update findManyPosts.ts
to include the user id in our where
parameter.
PS: I also rename it to findFollowingPosts
to better describe the function
// src/features/posts/findFollowingPosts.ts
import attachImage from '../images/attach-image'
import prisma from '@/utils/prisma'
const findFollowingPosts = async (userId: string) => {
const posts = await prisma.post.findMany({
include: {
user: true,
_count: {
select: {
liked_bys: true,
},
},
},
orderBy: { created_at: 'desc' },
// the user must the follower of the posters
where: {
user: {
followers: {
some: {
follower_id: userId,
},
},
},
},
})
return await Promise.all(
posts.map(async (post) => {
const postsWithImages = await attachImage(post, 'post')
const postsWithAuthorWithImages = {
...postsWithImages,
user: await attachImage(post.user, 'user'),
}
return postsWithAuthorWithImages
})
)
}
export default findFollowingPosts
Then, we can pass the user id from the API route
// src/pages/api/posts/index.ts
...
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<AllPostsData>
) {
const currentSession = await getServerSession(req, res, authOptions);
const posts = await findFollowingPosts(currentSession?.user.id ?? "");
res.status(200).json({ posts });
}
And we have filtered the posts successfully based on the follower logic
Stories
The implementation of stories would be almost equivalent to the posts, so I'll leave it to you. You can refer to my codes on GitHub.
Updating UI
Finally, let's go back to our index page
I just noticed that the StoryCarousel
is a bit off, let's fix it.
// src/components/carousel/StoryCarousel.tsx
// 640px to make sure all items will be displayed nicely
...
<Carousel
height={120}
maw={640}
classNames={{
control:
"p-0 border-0 text-gray-600 data-[inactive=true]:invisible data-[inactive=true]:cursor-default bg-white",
controls: "top-[20px]",
viewport: "rounded-sm w-[640px]",
}}
previousControlIcon={<BiChevronLeft size={24} />}
nextControlIcon={<BiChevronRight size={24} />}
slideSize={70}
slidesToScroll={4}
draggable={false}
align={"start"}
w="640"
slideGap={"sm"}
// this is to prevent over-scrolling, which is default behaviour
containScroll="trimSnaps"
>
...
Much better.
Logout
We will tackle the easier one, currently, we have a Logout button in our SideBar.tsx
which does nothing. We can use NextAuth's signOut
for that purpose
// src/components/sidebar/SideBar.tsx
...
<SideBarButton
text="Logout"
Icon={FiLogOut}
onClick={() => signOut()}
/>
...
User Profile
Likewise, let's add in the current user's id to the user profile button. I decided to move out profile button as it is more
import { AiOutlineHome, AiFillHome, AiOutlineSearch } from 'react-icons/ai'
import { ImSearch } from 'react-icons/im'
import { BsPlusSquare, BsFillPlusSquareFill } from 'react-icons/bs'
import { RiUser3Line, RiUser3Fill } from 'react-icons/ri'
import { FiLogOut } from 'react-icons/fi'
import Link from 'next/link'
import { useRouter } from 'next/router'
import Image from 'next/image'
import SideBarButton from './SideBarButton'
import { signOut, useSession } from 'next-auth/react'
const links = [
{ name: 'Home', route: '/', IconLine: AiOutlineHome, IconFilled: AiFillHome },
{
name: 'Search',
route: '/search',
IconLine: AiOutlineSearch,
IconFilled: ImSearch,
},
{
name: 'Create',
route: '/new',
IconLine: BsPlusSquare,
IconFilled: BsFillPlusSquareFill,
},
// we will update the route to use user name in next part
{
name: 'Profile',
route: '/profile',
IconLine: RiUser3Line,
IconFilled: RiUser3Fill,
},
]
const SideBar = () => {
const router = useRouter()
const currentPath = router.asPath
const session = useSession()
return (
<div className="fixed left-0 top-0 h-full w-[244px] border-r-[1px] border-solid border-gray-300 px-4 py-6">
<div className="flex h-full flex-col justify-between">
<div className="flex flex-col items-center">
<Link href="/">
<Image src="/brand.svg" alt="InstaNext" height="120" width="140" />
</Link>
<div className="mt-12 w-full space-y-2">
{links.map((link, index) => {
const isActive = link.route == currentPath
return (
<SideBarButton
key={index}
Icon={isActive ? link.IconFilled : link.IconLine}
text={link.name}
href={link.route}
isActive={isActive}
/>
)
})}
{/* This is the profile button */}
<SideBarButton
Icon={currentPath == `/users/${session.data?.user.name}` ? RiUser3Fill : RiUser3Line}
text={'Profile'}
href={`/users/${session.data?.user.name}`}
isActive={currentPath == `/users/${session.data?.user.name}`}
/>
</div>
</div>
<SideBarButton text="Logout" Icon={FiLogOut} onClick={() => signOut()} />
</div>
</div>
)
}
export default SideBar
Profile Picture
Lastly, Instagram will show the active user's username and profile picture on the top right, we will add that too
To do that, you can use a flex for the index.tsx
page and add in the avatar just like that, here's the new index.tsx
// src/pages/index.tsx
import Head from 'next/head'
import { useQuery } from '@tanstack/react-query'
import { getAllPosts } from '@/api/posts'
import Post from '@/components/posts/Post'
import StoryCarousel from '@/components/carousel/StoryCarousel'
import { signIn, useSession } from 'next-auth/react'
import { Avatar, Text } from '@mantine/core'
import Link from 'next/link'
export default function Home() {
const posts = useQuery({ queryFn: getAllPosts, queryKey: ['all-posts'] })
const session = useSession()
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<div className="flex flex-row items-start justify-center space-x-12">
<div className="flex flex-col items-center space-y-4">
<StoryCarousel />
{posts.isSuccess &&
posts.data.posts.map((post, index) => <Post post={post} key={index} />)}
</div>
<div className="w-[240px]">
<Link href={`/users/${session?.data?.user.name}`}>
<div className="flex items-center space-x-2">
<Avatar
src={session?.data?.user.image ?? ''}
alt={session?.data?.user.name ?? ''}
size="64px"
classNames={{ root: 'rounded-full' }}
/>
<Text className="font-semibold">{session?.data?.user.name}</Text>
</div>
</Link>
</div>
</div>
</>
)
}
And here's the final output
Summary
Got a little too lengthy here, but that's basically the end.
In this part, we have built an authentication system using NextAuth, and updated both frontend and backend so that they are aware of the current user. There's also a new sign-in page for the user to login.
In the next part, we will look into making POST requests in Next.js!
As usual, here's the link to the completed codes for this part on GitHub.