Up until now we've used the default login page NextAuth
provides us. But we had 2 problems with this:
- It's ugly and you can't customize it.
- There is an issue with page reloads.
To solve (some of) these, we will be implementing a custom sign in page. To do this, we will have to:
- Create a sign in page (some sort of form component)
- Create a
NextAuth
handler. - Do some
NextAuth
settings.
The code for this chapter is available on github: branch: customlogin.
Sign in page
We create a new page:
// frontend/src/app/(auth)/signin
import SignIn from '@/components/auth/signIn/SignIn';
export default function SignInPage() {
return <SignIn />;
}
Note that we are using a route group /(auth)
here. There will be more authentication pages like f.e. register and I want to group them together without this reflecting in the actual route. Meaning that our sign in page will be available at http://localhost:3000/signin
.
We don't do anything else in here besides import a <SignIn />
component that we are yet to make. I like to keep my app
folder for routing only and move everything else to components.
<SignIn />
component
Inside the components folder, we create an auth folder to group all our auth components. Here is our <SignIn />
component:
// frontend/src/components/auth/signIn/SignIn.tsx
import Link from 'next/link';
export default function SignIn() {
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
Sign in
</h2>
<div>
<p className='mb-4'>
Sign in to your account or{' '}
<Link href='/register' className='underline'>
create a new account.
</Link>
</p>
[form]
<div className='text-center relative my-8 after:content-[""] after:block after:w-full after:h-[1px] after:bg-zinc-300 after:relative after:-top-3 after:z-0'>
<span className='bg-zinc-100 px-4 relative z-10 text-zinc-400'>
or
</span>
</div>
<button className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'>
<span className='text-red-700 mr-2'>G</span> Sign in with Google
</button>
</div>
</div>
);
}
It looks like this:
Couple of notes: I know it's not very stylish but the point is you can customize it yourself. Since we will be using credentials login later I already added some placeholders for a form and a link to a register page that doesn't exist yet. But that is all for later chapters.
Sign in with Google button
We've already worked with the signIn
function that NextAuth
provides us. We used it in our <SignInButton />
component. Calling signIn()
just redirected us to the default NextAuth
sign in page.
But, there is a second use of the signIn
function. When we call it and we pass it a provider name, it initiates the authentication flow for said provider:
signIn('google');
As an optional second parameter it takes an options object. More on that later.
We call signIn
on a button click, so we need a client component. So we move the sign in with Google button into a separate client component, attach our signIn
function and import it into the <Login />
component:
// frontend/src/components/signIn/GoogleSignInButton.tsx
'use client';
import { signIn } from 'next-auth/react';
export default function GoogleSignInButton() {
return (
<button
className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'
onClick={() => signIn('google')}
>
<span className='text-red-700 mr-2'>G</span> Sign in with Google
</button>
);
}
Register our new sign in page
We are not ready to test yet. If we were to run our app now (not logged in) and click the navbar login button, it would still take us to the default NextAuth
login page. We need to tell NextAuth
we made a custom one. We do this in our authOptions
object.
We add a new pages property to the authOptions
object where we set the signin property to our sign in page:
// frontend/src/app/api/auth/[...nextauth]/authOptions.ts
export const authOptions: NextAuthOptions = {
//...
pages: {
signIn: '/signin',
},
};
Great! Run the app, sign out. Click sign in and we are redirected to our custom sign in page. But, there was a full reload of the page. Also, our url now is: http://localhost:3000/signin?callbackUrl=http%3A%2F%2Flocalhost%3A3000%2F
. So it added a callbackUrl parameter back to our homepage.
Click Sign in with Google. What happens:
- Page reloads.
- We are not redirected.
- But, we are logged in!
So, it works but we have some problems to solve.
NextAuth Redirect
The signIn
function takes as a seconds parameter an options object. One of these options is callbackUrl
.
signIn('google', { callbackUrl: 'someUrl' });
We've encountered this before. NextAuth
automatically passes callbackUrl
as a parameter in the url. To be honest, I expected to be redirected here with our current setup (without the options object). The NextAuth docs state:
The callbackUrl specifies to which URL the user will be redirected after signing in. Defaults to the page URL the sign-in is initiated from.
Based on that I kinda expected to be redirect to the home page by default. It doesn't. So, let's update our signIn
function, set callbackUrl to home and test it:
// frontend/src/components/signIn/GoogleSignInButton.tsx
onClick={() => signIn('google', { callbackUrl: '/' })}
It works, we got redirected. A note here, NextAuth
only accepts either relative paths or absolute urls on the same domain as the app. Always redirecting to the homepage is possible but in our case we want to redirect the user back to the previous page he or she was on. We update the function with the useSearchParams
hook:
// frontend/src/components/signIn/GoogleSignInButton.tsx
'use client';
import { signIn } from 'next-auth/react';
import { useSearchParams } from 'next/navigation';
export default function GoogleSignInButton() {
const searchParams = useSearchParams();
const callbackUrl = searchParams.get('callbackUrl') || '/';
return (
<button
className='bg-white border border-zinc-300 py-1 rounded-md w-full text-zinc-700'
onClick={() => signIn('google', { callbackUrl })}
>
<span className='text-red-700 mr-2'>G</span> Sign in with Google
</button>
);
}
We take callbackUrl from searchParams and simply pass it into signIn
options. We also provide a fallback '/'
to our home route. The callbackUrl from searchParams may be empty, f.e. when a user directly surfs to localhost:3000/signin
.
To test this out, me made a little test page so we can sign in from there:
// frontend/src/app/test/page.tsx
export default function page() {
return <div>test page</div>;
}
We open up localhost:3000/test
, logout, login and it redirects us to /test
again. Great! I tried adding some searchParams (/test?foo=bar
) and those were also passed down with no problem. Finally, I directly opened up /signin
, logged out and in, and as expected, we were sent to the home page.
Note: there is another signIn
option called redirect
that takes a boolean as value. But this option is only valid for the email and credentials providers. We will in fact be using this later on.
Guarded sign in page
One last detail. We are going to update our <SignIn />
component. If our user is signed in, we will display a message saying "you're signed in" instead of showing the sign in with Google button. Why? To not confuse the user.
// frontend/src/components/signIn/SignIn.tsx
import Link from 'next/link';
import GoogleSignInButton from './GoogleSignInButton';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/authOptions';
export default async function SignIn() {
const session = await getServerSession(authOptions);
return (
<div className='mx-auto my-8 p-8 max-w-lg bg-zinc-100 rounded-sm'>
<h2 className='text-center text-2xl text-blue-400 mb-8 font-bold'>
Sign in
</h2>
{session ? (
<p className='text-center'>You are already signed in.</p>
) : (
<div>...</div>
)}
</div>
);
}
The reloads
Even with our custom login page, we still get the full page reloads when clicking:
- sign in button
- sign in with Google button
- sign out button
Is this bad? Kinda yes. The entire app gets reloaded, you have to download everything again, the screen flickers while components are mounted, and it takes some time. This is not a nice flow. Besides these, not the end of the world. Can we fix them? I spend quite a lot of time on looking this up and trying things out.
The reload when clicking the sign in with Google
is not fixable. But, here's the thing, every webapp where you sign in with Google reloads on signing in. It's normal. The sign out button is also not fixable. There will be a reload but same as the previous button, this is normal behavior. Finally, the sign in button we can and will fix just below.
Fix the reload issue when clicking the sign in button
You may have already guessed this one. Why don't we just link to our sign in page instead of using the button that calls signIn
. We can but it leads to some complications.
First complication, we lost the callbackUrl parameter. Earlier, when calling signIn
, a searchParam callbackUrl
was automatically added by NextAuth
. When we remove the button and use a Link that callbackUrl is obviously no longer there. We will have to build that ourselves. So, we will replace <SignInButton />
with a new component: <SignInLink />
:
// frontend/src/components/header/SignInLink.tsx
'use client';
import Link from 'next/link';
import useCallbackUrl from '@/hooks/useCallbackUrl';
export default function SignInLink() {
const callbackUrl = useCallbackUrl();
return (
<Link
href={`/signin?callbackUrl=${callbackUrl}`}
className='bg-sky-400 rounded-md px-4 py-2'
>
sign in
</Link>
);
}
Remember, this is the sign in link for our Navbar component. So, we need to link to /signin
. But, to this, we need to add a searchParam callbackUrl
. What is the value of this? Our current url + all of its own searchParams. Here is an example: if we are on page /test?foo=bar
, we want our <SignInLink />
to link to: /signIn
+ ?callbackUrl=/test?foo=bar
. To create the callback url value, we made a custom hook so we can reuse it. This is the hook:
// frontend/src/hooks/useCallbackUrl.ts
import { usePathname, useSearchParams } from 'next/navigation';
export default function useCallbackUrl() {
const pathname = usePathname();
const params = useSearchParams().toString();
// if there are no params, don't add the ?
const callbackUrl = `${pathname}${params ? '?' : ''}${params}`;
return callbackUrl;
}
Some notes:
- We use a relative url.
- There is no need to
encodeURIComponent
becauseNext
covers this for us in theusePathname
anduseSearchParams
hooks. - There is a flaw in this. If we were to go from
/test
via our new link to the sign in page, we would have following route:/signin?callbackUrl=/test
. If we click on the navbar sign in button again, this will be our route:/signin?callbackUrl=/signin?callbackUrl=%2Ftest
. Our callbackUrl becomes the current page:/signin
+ callbackUrl parameter. It's like a feedback loop. Funnily enough, theNextAuth
signIn
function has the same problem. So, I'm going to ignore this.
On the upside: we can run this and it works! Going to the sign in page no longer reloads the page. Testing this further, the callbackUrl works. We are correctly redirected after signing in (but with a full page reload - as expected).
Conclusion
We made a custom login page. This is an absolute necessity and in itself it's not very difficult. We also learned to use the signIn
function with a provider and an options argument. This allows us to start the authentication flow with NextAuth
manually. The options object includes the callbackUrl searchParam.
Finally, we tackled part of the hard page reloads in our sign in and sign out flow. We only managed to correct the navigation to the sign in page. This created a complication in that we had manually pass the callbackUrl parameter to the url.
Is it worth it? The custom login is a must! The hard reloads for me were disruptive. I like that we at least solved one of them. On top of that, we solved it with a minimal amount of code. This chapter is quite long but in the end, the code is manageable and the solution is good.
In the next chapter we will start integrating with Strapi
.
If you want to support my writing, you can donate with paypal.