DEV Community

Peter Jacxsens
Peter Jacxsens

Posted on • Updated on

6/ NextAuth: creating a custom login page

Up until now we've used the default login page NextAuth provides us. But we had 2 problems with this:

  1. It's ugly and you can't customize it.
  2. 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 />;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

It looks like this:

NextAuth custom signin page

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');
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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',
  },
};
Enter fullscreen mode Exit fullscreen mode

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!

nextauth custom sign in page

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' });
Enter fullscreen mode Exit fullscreen mode

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: '/' })}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

nextauth already signed in

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

Some notes:

  • We use a relative url.
  • There is no need to encodeURIComponent because Next covers this for us in the usePathname and useSearchParams 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, the NextAuth 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.

Top comments (0)