Protecting routes with NextAuth in Nextjs

esponges

Fernando González Tostado

Posted on January 4, 2023

Protecting routes with NextAuth in Nextjs

I've been recently using NextAuth for authenticating users in my projects. NextAuth makes the task super simple and with minimal boilerplate, secure and completely serverless. Love it!

Now that the authentication process is handled with NextAuth, we'll most certainly need to protect some routes, maybe the user details, or any other route that we shouldn't let unauthenticated users access.

While I like protecting routes in Nextjs with its built in middleware, this strategy won't work with NextAuth if you are using database sessions (only with JWT), or if you don't want to over complicate the process, this alternative will be perfect.

Time to get hands on

I expected that you already have your app with NextAuth, if you don't, check this app repository and the docs, it shouldn't take you more than 5 minutes.

The guard wrapper

We'll create a wrapper component that will use the session status with useSession to determine whether the user should be allowed to access, or not.

// components/layouts/protectedLayouts.tsx

import { useSession } from 'next-auth/react';
import { useRouter } from 'next/router';
import { useEffect } from 'react';

type Props = {
  children: React.ReactElement;
};

/*
  add the requireAuth property to the page component
  to protect the page from unauthenticated users
  e.g.:
  OrderDetail.requireAuth = true;
  export default OrderDetail;
 */

export const ProtectedLayout = ({ children }: Props): JSX.Element => {
  const router = useRouter();
  const { status: sessionStatus } = useSession();
  const authorized = sessionStatus === 'authenticated';
  const unAuthorized = sessionStatus === 'unauthenticated';
  const loading = sessionStatus === 'loading';

  useEffect(() => {
    // check if the session is loading or the router is not ready
    if (loading || !router.isReady) return;

    // if the user is not authorized, redirect to the login page
    // with a return url to the current page
    if (unAuthorized) {
      console.log('not authorized');
      router.push({
        pathname: '/',
        query: { returnUrl: router.asPath },
      });
    }
  }, [loading, unAuthorized, sessionStatus, router]);

  // if the user refreshed the page or somehow navigated to the protected page
  if (loading) {
    return <>Loading app...</>;
  }

  // if the user is authorized, render the page
  // otherwise, render nothing while the router redirects him to the login page
  return authorized ? <div>{children}</div> : <></>;
};

Enter fullscreen mode Exit fullscreen mode

This wrapper will allow authenticated users to see its children contents or redirect the user if he's not.

The subscription in the dependency array of the useEffect hook will make sure that we check this logic every time that there's a navigation or session update.

I've also added a loading return statement for the cases where the user reloads or navigates directly to the page so he doesn't get kicked out of the page before being sure about his session status.

The key is that this wrapper will only wrap the components that should be protected. Other components won't be affected by it. How can we make this while not having to manually wrap component by component and thus keeping our code DRYer?

By using the _app.tsx component. Remember that this component runs in every route.

// pages/_app.tsx

// add requireAuth to AppProps
type AppPropsWithAuth = AppProps & {
  Component: {
    requireAuth?: boolean;
  };
};


export default function App({ Component, pageProps }: AppPropsWithAuth) {
  return <SessionProvider session={pageProps.session}>
    {Component.requireAuth ? (
      <ProtectedLayout>
        <Component {...pageProps} />
      </ProtectedLayout>
    ) : (
      <Component {...pageProps} />
    )}
  </SessionProvider>;
}
Enter fullscreen mode Exit fullscreen mode

In the return statement from App by using a ternary the ProtectedLayout component will only wrap on the components where we add the requireAuth property.

I've already updated the AppProps so the component will accept this property and the compiler don't scream at us. If you don't use TS this is not necessary.

The protected components

Now, in any component where we want it to be protected we should add this property with a true value like so:

// pages/protected/index.tsx

import { useSession } from 'next-auth/react';

const ProtectedPage = () => {
  const { data: session } = useSession();
  return (
    <div>
      <h1>Protected Page</h1>
      <p>Hi {session?.user?.name}!</p>
    </div>
  );
};

// add the requireAuth property to the page component
ProtectedPage.requireAuth = true;

export default ProtectedPage;
Enter fullscreen mode Exit fullscreen mode

If we don't add this property, the ternary logic will be false (since .requireAuth is undefined) and the user will be free to navigate to that path regardless of his session status.

Redirecting after authentication

Finally, we'll make use of the redirectUrl query param that it's passed to the redirection route when the user is not authorized.

We'll create a custom login page and override default one from NextAuth.

Our signIn component could be something like this:

// pages/auth/signIn/index.tsx
import { signIn, useSession } from 'next-auth/react';
import Link from 'next/link';
import { useRouter } from 'next/router';
import { useEffect, useState } from 'react';

const SignInPage = () => {
  const [isRedirecting, setIsRedirecting] = useState(false);
  const { data: session } = useSession();
  const router = useRouter();

  useEffect(() => {
    if (session && !isRedirecting && router.isReady) {
      // display some message to the user that he is being redirected
      setIsRedirecting(true);
      setTimeout(() => {
        // redirect to the return url or home page
        router.push(router.query.returnUrl as string || '/' );
      }, 2000);
    }
  }, [session, isRedirecting, router]);

  const handleSignIn = () => {
    signIn('discord');
  };

  return (
    <div>
      <h1>Sign In</h1>
      <p>Sign in to access protected pages</p>
      {session ? (
        <div>
          <p>Currently signed in as {session?.user?.name}</p>
          <p>Redirecting to home page...</p>
          <Link href='/'>
            <button>Go to home</button>
          </Link>
        </div>
      ) : (
        <button onClick={handleSignIn}>Sign In</button>
      )}
    </div>
  );
};

export default SignInPage;
Enter fullscreen mode Exit fullscreen mode

This page will have two uses, the first one, is of course displaying our login providers options to the user.

And second, NextAuth will redirect the user back to this login page, and we'll pick the router.query.returnUrl string and if present, redirect the user to the page that he was trying to access before.

Remember the useEffect from components/layouts/protected.tsx?

Don't forget to update the pathname to our new sign in page — /auth/signIn.

  useEffect(() => {
    // check if the session is loading or the router is not ready
    if (loading || !router.isReady) return;

    // if the user is not authorized, redirect to the login page
    // with a return url to the current page
    if (unAuthorized) {
      console.log('not authorized');
      router.push({
        pathname: '/auth/signIn',
        query: { returnUrl: router.asPath },
      });
    }
  }, [loading, unAuthorized, sessionStatus, router]);
Enter fullscreen mode Exit fullscreen mode

We also must pass the new sign in path to [...nextAuth].ts for the override.

// [...nextauth].ts
export default NextAuth({
  // ...
  pages: {
    signIn: '/auth/signin',  // Displays signin buttons
  },
  // ...
})
Enter fullscreen mode Exit fullscreen mode

And that was it!

If you want to see the full code of the sample app please check the original code.

Sources:

💖 💪 🙅 🚩
esponges
Fernando González Tostado

Posted on January 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related