How to Add Global Authentication Checks in Next.js with NextAuth


Implementing site-wide, global authentication can be very complex. How do we require authentication on a per-page basis and route users to a login page when unauthenticated?

There are some very solid, clever solutions for global authentication checks in Next.js (a post I refer back to from time to time).

The most important takeway from this post is the utility of simple higher order components (HOC) to authenticate on the page level.

In this article, I want to provide some explanation to a solution that I came across in this GitHub issue for global authentication checks in Next.js using next-auth.

The code snippets in this article require NextAuth.js v4. Check out how to upgrade to version 4.

Note that this is an alternative to solutions that use Next.js 12’s middleware for global authentication checks.

1. Specify Auth Requirements in Pages

The goal here is to perform as little authentication work on the page level and to add the majority of the code in a reusable component.

For each page on which we want to force authentication, we will add a custom auth property to the component function.

// pages/protected.jsx
const Protected = () => {
  const [session] = useSession();
  return (
    <>
      <h1>A Protected Page</h1>
      <span>My name is {session.user.name}</span>
    </>
  );
};
Protected.auth = true;
export default Protected;

This is all we will need on the page level.

Notice how we won’t need to access the loading variable of useSession() because session will never be null while inside this component.

If session is null or loading is true, then we don’t want this component to be rendered at all but rather redirected to a /login page.

2. Implement Auth Check

In another file (or even inside _app.jsx itself), we want to add our authentication check.

// components/auth.jsx
import { useRouter } from "next/router";
import { useSession } from "next-auth/react";
const Auth = ({ children }) => {
  const { data: session, status } = useSession();
  const loading = status === "loading";
  const hasUser = !!session?.user;
  const router = useRouter();
  useEffect(() => {
    if (!loading && !hasUser) {
      router.push("/login");
    }
  }, [loading, hasUser]);
  if (loading || !hasUser) {
    return <div>Waiting for session...</div>;
  }
  return children;
};

We’ll first grab session and loading from useSession().

If we are done loading…

If we are done loading, then two things can happen:

  1. !hasUser: the useEffect() hook will redirect us to the login page
  2. hasUser: the user is authenticated, and the child component is properly rendered.

If we are loading…

If we are loading (still fetching session), we’ll render a loading screen ("Waiting for session...").

Notice that we’ll also render this loading screen if the user is not yet authenticated (!hasUser). This condition functions more as a logical safeguard. It’s technically not needed for two reasons:

  1. !loading: the useEffect() hook should redirect us to the login page if we’re done loading and have no session
  2. loading: we simply won’t have a session value yet if we are still loading

session vs loading

session !session
loading Render "Waiting for session..." Render "Waiting for session..."
!loading Render the page Redirect to /login

Note that loading && session should logically never return true, but in our implementation, we would render the loading screen.

3. Conditionally Run Auth Check

We’ll first want to import this auth component into our _app.jsx.

We’ll wrap our <Component /> with the <Auth> component we wrote whenever the rendered component declares the auth flag to be true.

// pages/_app.jsx
import { SessionProvider } from "next-auth/react"
import { Auth } from "@/components/auth";
const App = ({ Component, pageProps }) => (
  <SessionProvider session={pageProps.session}>
    {Component.auth ? (
      <Auth>
        <Component {...pageProps} />
      </Auth>
    ) : (
      <Component {...pageProps} />
    )}
  </SessionProvider>
);
export default App;

4. Possible Modifications

The beauty of this approach is that it is easily extendable to include page-level role-based access control (RBAC), custom loading screens, custom routes, etc.

Role-Based Access Control (RBAC)

For instance, in an authenticated page, we can change this added property to be an object that holds information specific to the page.

If we were designing a site for authors to create blog posts, we might want multiple roles.

  1. user: can read and subscribe to specific authors
  2. author: can post articles for users
  3. admin: can add and remove authors

We may want different roles to have access to different pages.

First, we would set the expected role for each authenticated page.

// pages/protected.jsx
Protected.auth = {
  role: "author",
};

Then, we would pass the required role for the currently accessed page.

// pages/_app.jsx
<Auth role={Component.auth.role}>
  <Component {...pageProps} />
</Auth>

Finally, we can make comparisons against the current user.

// components/auth.jsx
const Auth = ({ children, role }) => {
  useEffect(() => {
    if (!loading) {
      if (!hasUser) {
        router.push("/login");
      } else if (!hasAccess(session, role)) {
        router.push("/permission-denied");
      }
    }
  }, [hasUser, loading]);
};

Custom Loading Screens

The last thing to note about this approach is that it works very nicely with custom loading screens.

// components/auth.jsx
...
import { LoadingScreen } from "@/components/LoadingScreen";
const Auth = ({ children }) => {
  ...
  if (loading || !hasUser) {
    return <LoadingScreen />;
  }
  ...
};

Since we handle everything on the client-side, we have full control of the loading screen and can show, for instance, a skeleton page as we wait for the session to be fetched.

If we were to fetch the session on the server side with getServerSideProps(), we would render block the entire page.

This is a massive benefit when diving deep into our site’s user experience and perceived performance.