Protected Routes with Supabase and Next.js

Some routes of your web application are meant for authenticated users only. For example, a /settings page can only be used if the user is signed in.

You could solve this client-side: Once the page renders, you check whether a user is signed in; if they are not, you redirect the user to the sign in page.

There is a problem with this, though. The page will start to render, so you either have to prevent everything from rendering until this check is done or you will see a partially rendered page suddenly redirected to the sign in page.

Luckily with Next.js, we can do this check server-side. Here's an outline of how we're going to do it:

  • Write an API route /api/auth to set a cookie based on whether a user signs in or out.
  • Register a listener with Supabase's onAuthStateChange to detect a sign in or sign out and call this API route.
  • Extract a function enforceAuthenticated to protect a route with one line of code.

Setting an Auth Cookie

Supabase provides a setAuthCookie function defined in @supabase/gotrue-js. This function takes a Next.js (or Express) request and response and sets or removes an auth cookie.

To make use of it, we introduce an API route /api/auth and simply call setAuthCookie, passing it the request and response objects.

// pages/api/auth.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { supabase } from './../../components/supabaseClient';

const handler = (req: NextApiRequest, res: NextApiResponse) => {
    supabase.auth.api.setAuthCookie(req, res);
};

export default handler;

setAuthCookie behaves like this:

  • The request req must be POST request.
  • The request body must contain two elements: a session and an event.
  • The session contains session data (as is provided by supabase.auth.session() for example).
  • The event is either SIGNED_IN indicating a sign in or SIGNED_OUT indicating a sign out.

Getting this data is easy.

Updating the Auth Cookie

To keep the auth cookie up to date, we have to listen to changes in the authentication state of Supabase. On every change, we have to call the /api/auth endpoint to update the cookie accordingly.

For this, Supabase provides the onAuthStateChange function, which allows us to register a listener. This listener is called whenever a user signs in or out.

The following snippet should be used within the App component (usually _app.tsx or _app.jsx).

useEffect(() => {
    const { data: authListener } = supabase.auth.onAuthStateChange((event, session) => {
        updateSupabaseCookie(event, session);
    });

    return () => {
        authListener?.unsubscribe();
    };
});

async function updateSupabaseCookie(event: AuthChangeEvent, session: Session | null) {
    await fetch('/api/auth', {
        method: 'POST',
        headers: new Headers({ 'Content-Type': 'application/json' }),
        credentials: 'same-origin',
        body: JSON.stringify({ event, session }),
    });
}

The listener is passed two arguments when the authentication state changes: an event indicating whether the user signed in or out and the current session. This is exactly what the /api/auth endpoint needs to update the auth cookie. Using fetch, we send a simple POST request to it to reflect this change.

👉 I recommend extracting this code into a custom hook (which you can call useUpdateAuthCookie for example).

Changes in the authentication state in the frontend are now reflected in the auth cookie. Why do we update such a cookie? So we can use it server-side when using functions like getServerSideProps.

Protecting Routes

We can now protect a route by checking the auth cookie in getServerSideProps. If the user is signed in, we simply return; otherwise, we redirect the user to a sign in page.

Let's assume this sign in page can be found at /signin.

export async function getServerSideProps({ req }) {
    const { user } = await supabase.auth.api.getUserByCookie(req);

    if (!user) {
        return { props: {}, redirect: { destination: '/signin' } };
    }

    return { props: {} };
}

Depending on how many routes you must protect, it's a good idea to extract this code and reuse it. For my projects, I use a function called enforceAuthenticated. This function takes an optional getServerSideProps function and delegates to it in the case that the user is signed in.

import { GetServerSideProps } from 'next';
import { supabase } from './supabaseClient';

const enforceAuthenticated: (inner?: GetServerSideProps) => GetServerSideProps = inner => {
    return async context => {
        const { req } = context;
        const { user } = await supabase.auth.api.getUserByCookie(req);

        if (!user) {
            return { props: {}, redirect: { destination: '/signin' } };
        }

        if (inner) {
            return inner(context);
        }

        return { props: {} };
    };
};

export default enforceAuthenticated;

With this, quickly protecting a route becomes a one-liner:

// pages/protected.tsx
import enforceAuthenticated from '../components/enforceAuthenticated';

export default function ProtectedPage() {
    return <div>Protected Page</div>
}

export const getServerSideProps = enforceAuthenticated();

When we go to /protected now, we are either redirected to /signin when we are not signed in or the ProtectedPage is rendered.

Recap

Here's what we did:

  • We created an API route /api/auth which updates an auth cookie based on a session and an event indicating a sign in or sign out.
  • We created a listener in the App component to send every update to the authentication state to the /api/auth endpoint, thereby updating the auth cookie.
  • In our server-side code, we used the getUserByCookie function to determine whether a user is signed in or out. Based on this, we either render the page or redirect the user to a sign in page.
  • We introduced a function enforceAuthenticated to reuse this functionality on as many routes as we want.

If you enjoyed this post, you can follow me on Twitter 🙏

Credits

When I started out with Supabase, I read:

It's a great post and the first time I saw the setAuthCookie/getUserByCookie combination. Give it a read, it's an excellent post!

54