Authentication in Next.js with Supabase and Next 13

mryechkin

Mykhaylo Ryechkin πŸ‡ΊπŸ‡¦

Posted on January 25, 2023

Authentication in Next.js with Supabase and Next 13

Intro

IMPORTANT: Note that this guide is now outdated, and is using an older version of the Next.js Auth Helpers (pinned at 0.6.1). Please see the updated guide for the latest version that utilizes the Proof Key for Code Exchange (PKCE) flow.

Awhile back I wrote an article on using Supabase to implement user authentication in a Next.js app. As it often goes in the world of open-source web, things evolve quickly and a lot can change in just over a year, and so is the case with Supabase.

In this post, I would like to bring you an updated guide on implementing user auth with Supabase and Next.js in 2023.

NOTE: Make sure to read the original post first if you haven't yet, as we'll be building on the main concepts covered there. Code for the Supabase v1 implementation can be found under the v1 branch in GitHub.

What's New?

So, what all has changed? Let's start with the big stuff.

Next 13

In October 2022, Next.js team announced Next 13, and with it came the new app directory architecture.

Though officially still in beta (as of January 2023), the app directory offers a great new way of architecting our apps, and introduces new features like nested layouts and support for React Server Components.

I wanted to explore how this new paradigm would work with Supabase, and try some of the capabilities in a familiar project. Happy to say that this exploration went well, and we'll go over one approach of using the new app architecture in this guide.

Supabase v2

Next up, Supabase released v2 of their JavaScript client library (supabase-js), which brought with it a number of developer experience type improvements, and streamlined how we use some of the API's. A number of methods were also deprecated in this new release, which we'll cover later in this guide.

Supabase Auth Helpers

In addition to the new version of the client library, Supabase also introduced new Auth Helpers earlier this year, which is a collection of libraries and framework-specific utilities for working with user authentication in Supabase.

These utilities eliminate the need to keep writing the same boilerplate code over and over (e.g. initializing Supabase client, setting cookies, etc.), and let us focus on our application code instead. We'll be utilizing their Next.js helpers library in our project.

Supabase UI (Deprecated)

Lastly, the Auth component from the Supabase UI library which we were using to render our auth screens, and handle all the related flows and UI logic (Sign In, Sign Up, Reset Password, etc.) has been deprecated. Components from the original @supabase/ui package were moved into the main Supabase repo, and the package is now deprecated.

The Auth component itself now has a new name "Auth UI", and lives in its own separate repo. Though originally intending to use it, as I began migrating the code I've found this new component to not work quite as well as I'd hoped, getting in the way more than helping. For that reason, I've decided to abandon it in this guide, and build one instead.

Fortunately, building a component like this from scratch isn't all that difficult thanks to libraries like Formik, so we'll use that to help us handle all of our form logic. This has the added benefit of giving us full control of the auth flow, and the ability to customize the form UI however we'd like, without being limited by Supabase's customization options.

Project Setup

In the interest of saving some time, we'll start with the project from the original post, as we do end up re-using a bunch of existing code.

The code for that is in GitHub for your reference. If you're doing this for the first time, clone the v1 branch as your starting point.

Updating Dependencies

First things first, we'll need to update Next.js and Supabase to the latest versions, and install Formik (+ related dependencies) and the Next.js Auth Helpers:

npm install next@latest react@latest react-dom@latest @supabase/supabase-js@2.21.0 @supabase/auth-helpers-nextjs@0.6.1 classnames formik yup
Enter fullscreen mode Exit fullscreen mode

We'll also utilize Tailwind's Forms Plugin to make it easier to style our Auth component, so let's install that as well:

npm install -D @tailwindcss/forms
Enter fullscreen mode Exit fullscreen mode

You can also remove @supabase/ui, since we won't be using it anymore:

npm uninstall @supabase/ui
Enter fullscreen mode Exit fullscreen mode

With dependencies updated, we'll need to configure Next to use the new app directory (make sure you're on version 13 or above). To enable this, add (or update) next.config.js in the project's root folder:

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: {
    appDir: true,
  },
};

module.exports = nextConfig;
Enter fullscreen mode Exit fullscreen mode

This will allow us to start moving some of our existing code from the pages directory to the app folder. Note that putting the app folder under src also works, which is how the previous project was setup.

Make sure to read more about the new app directory if you aren't familiar with the new features.

Updating Tailwind Config

Last thing we need to do is specify the @tailwindcss/forms plugin in our Tailwind config file:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
const forms = require('@tailwindcss/forms');

module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  darkMode: 'media',
  plugins: [forms],
};
Enter fullscreen mode Exit fullscreen mode

Supabase API keys

To use Supabase, we'll need to have our Supabase API key and URL setup.

If you haven't already, create an .env file and specify the corresponding values from Supabase dashboard (refer to the previous post for more details):

// .env
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
Enter fullscreen mode Exit fullscreen mode

Supabase Client

With API keys setup, let's now create the Supabase Client so that we can interact with Supabase API's. We'll be using the Next.js Auth Helpers library installed earlier for this.

IMPORTANT: The instructions below are using @supabase/auth-helpers-nextjs@0.6.1. At the time of writing, there is an issue with password reset flow in the latest version. Additionally, some of the methods used in this guide have been renamed in version 0.7.0. See the project repo in GitHub for any future changes.

In order to support both the new Server Components and the Client Components, we'll need to have two different kinds of a Supabase Client:

  • Browser Client: for use with Client Components in the browser, for example within a useEffect
  • Server Component Client: for use specifically with Server Components

Create two files - supabase-browser.js and supabase-server.js - one for each type of client. We'll put these in the src/lib folder:

└──src/
    └── lib/
         β”œβ”€β”€ supabase-browser.js
         └── supabase-server.js
Enter fullscreen mode Exit fullscreen mode

Browser Client

// src/lib/supabase-browser.js
import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs';
// NOTE: `createBrowserSupabaseClient` has been renamed to `createPagesBrowserClient` in version `0.7.0`

const supabase = createBrowserSupabaseClient();

export default supabase;
Enter fullscreen mode Exit fullscreen mode

Server Component Client

// src/lib/supabase-server.js
import { createServerComponentSupabaseClient } from '@supabase/auth-helpers-nextjs';
// NOTE: `createServerComponentSupabaseClient` has been renamed to `createServerComponentClient` in version `0.7.0`
import { cookies, headers } from 'next/headers';

export default () =>
  createServerComponentSupabaseClient({
    headers,
    cookies,
  });
Enter fullscreen mode Exit fullscreen mode

Note that this needs to export a function, as the headers and cookies are not populated with values until the Server Component is requesting data (according to Supabase docs).

With the clients setup, we can now import them in our pages and components to interact with Supabase.

E.g. in a Client Component:

import supabase from 'src/lib/supabase-browser';

function ClientComponent() {
  useEffect(() => {
    async function getData() {
      const { data } = await supabase.auth.getSession();
      // ...
    }
    getData();
  }, []);
  // ...
}
Enter fullscreen mode Exit fullscreen mode

E.g. in a Server Component:

import createClient from 'src/lib/supabase-server';

async function ServerComponent() {
  const supabase = createClient();

  const { data } = await supabase.auth.getSession();

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Application Structure

The overall page structure and routes will largely remain the same as before, so we'll get to re-use a bunch of existing code. The biggest change will be moving all page code from the pages folder to the new app directory. As we do that, we'll also need to refactor some things to align with this new paradigm.

Our project folder structure will look like the following:

└── src/
    β”œβ”€β”€ app/
    β”‚   β”œβ”€β”€ profile/
    β”‚   β”‚   └── page.js
    β”‚   β”œβ”€β”€ head.js
    β”‚   β”œβ”€β”€ layout.js
    β”‚   └── page.js
    β”œβ”€β”€ components/
    β”‚   β”œβ”€β”€ Auth/
    β”‚   β”‚   β”œβ”€β”€ index.js
    β”‚   β”‚   β”œβ”€β”€ ResetPassword.js
    β”‚   β”‚   β”œβ”€β”€ SignIn.js
    β”‚   β”‚   β”œβ”€β”€ SignUp.js
    β”‚   β”‚   └── UpdatePassword.js
    β”‚   └── AuthProvider.js
    └── middleware.js
Enter fullscreen mode Exit fullscreen mode

Let's go through what each of these are.

Pages and Routes

Home: /

This is the main Home page, showing the Auth component if no session is found (ie. user needs to sign-in), or a link to the Profile page if there is an active session with a valid user.

This page also handles the "Update Password" flow, and will be redirected to from the link in the reset password email sent by Supabase (this URL is configurable in case you'd like to use a different route).

Previously in src/pages/index.js, this page will now be in src/app/page.js.

Profile: /profile

This is an authenticated Profile page, showing some basic user info for the current user. If no session is found, it will redirect to /.

Previously in src/pages/profile.js, this page code will now be in src/app/profile/page.js.

And that's it as far as page routes go.

Note that in Next 13, pages are Server Components by default, but can be set to Client Components via the 'use client' directive. More on this and how it impacts our code a bit later.

Root Layout

In the previous solution, we built our own Layout component, and used it as the top-level wrapper for each individual page, ie:

import Layout from 'src/components/Layout';

export default function Home() {
  return <Layout>{/** Page content */}</Layout>;
}
Enter fullscreen mode Exit fullscreen mode

With Next 13 and the app directory, this can now be done using shared layouts ✨

Since we only have a single layout shared across the whole app, we can just create a single root layout.js file in src/app, which will be shared by all children routes and pages. We'll call this RootLayout.

Using the existing Layout component as our starting point, create src/app/layout.js and paste the following (feel free to adjust styling as needed):

// src/app/layout.js
import 'src/styles/globals.css';

export default async function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <div className="flex min-h-screen flex-col items-center justify-center py-2">
          <main className="flex w-full flex-1 shrink-0 flex-col items-center justify-center px-8 text-center sm:px-20">
            <h1 className="mb-12 text-5xl font-bold sm:text-6xl">
              Next.js with <span className="font-black text-green-400">Supabase</span>
            </h1>
            {children}
          </main>
        </div>
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note how we need to include <html> and <body> tags here as well. If you'd like to think of it in terms of the old pages directory, the root layout.js essentially combines the concepts of _app.js and _document.js together into one.

One big benefit of a root layout is that state is preserved on route changes, and the layout doesn't unnecessarily rerender. Layouts can also be nested, though we won't be needing that in this project.

IMPORTANT: Root layout is a Server Component by default, and can NOT be set to a Client Component. Any parts of your layout that require interactivity will need to be moved into separate Client Components (which can be marked with the 'use client' directive).

Read more on this in "moving Client Components to the leaves" in Next.js docs for additional information.

Auth Provider

To handle our auth-related logic on the client side, we need an AuthProvider, which is essentially a React context provider.

This component is a top-level wrapper in our application, providing things like user and session objects, signOut method and a useAuth hook to its children components.

Previously found in the src/lib/auth.js file in the original implementation, we'll move this to src/components/AuthProvider.js to keep our folder and naming structure consistent. For now, keep all of the existing code - we'll update it as we go along.

In the original implementation, this component was used in the _app.js:

// src/pages/_app.js
function MyApp({ Component, pageProps }) {
  return (
    <AuthProvider supabase={supabase}>
      <Component {...pageProps} />
    </AuthProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

With the move to app directory, we'll need to find it a new home. Considering that this will be shared by all pages, the best place for it is in the RootLayout.

Update src/app/layout.js with the following:

// src/app/layout.js
import { AuthProvider } from 'src/components/AuthProvider';

import 'src/styles/globals.css';

export default async function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        {/* ... */}
        <AuthProvider>{children}</AuthProvider>
        {/* ... */}
      </body>
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

One important thing to note from Next.js beta docs:

In Next.js 13, context is fully supported within Client Components, but it cannot be created or consumed directly within Server Components. This is because Server Components have no React state (since they're not interactive), and context is primarily used for rerendering interactive components deep in the tree after some React state has been updated.

Given the above, we'll need to make AuthProvider a Client Component, since it uses React context.

To do this, add 'use client' at the very top of the src/components/AuthProvider.js file:

'use client';

import { createContext, useContext, useEffect, useMemo, useState } from 'react';

// ...

export const AuthProvider = (props) => {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Note that even though AuthProvider is now a Client Component, it can still be imported and used in the RootLayout (which is a Server Component).

Middleware

Next.js middleware is used to run code before a request is completed, and can be configured to run that code only on specific routes. From Supabase docs we learn that:

Since we don't have access to set cookies or headers from Server Components, we need to create a Middleware Supabase client and refresh the user's session by calling getSession(). Any Server Component route that uses a Supabase client must be added to this middleware's matcher array. Without this, the Server Component may try to make a request to Supabase with an expired access_token.

So, we'll need to add Next.js middleware in our app. Create a middleware.js file at the root of our src folder, and add the following:

import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs';
import { NextResponse } from 'next/server';

export async function middleware(req) {
  const res = NextResponse.next();

  const supabase = createMiddlewareSupabaseClient({ req, res });

  const {
    data: { session },
  } = await supabase.auth.getSession();

  return res;
}

export const config = {
  matcher: ['/profile'],
};
Enter fullscreen mode Exit fullscreen mode

Here we're using the createMiddlewareSupabaseClient() function from the Next.js Auth Helpers to create a middleware Supabase client, and we're also configuring the matcher array to run this code on the /profile route, as that's the only route using Server Components in our case. If you have additional routes utilizing Server Components, you'll need to specify them here as well.

Migrating to Supabase v2

With the core of our app code now in place, it's time to make some updates.

There were a few methods deprecated in Supabase v2, so we'll need to update how we use those in our AuthProvider.

Session Listener

If you recall, we have a useEffect inside our AuthProvider that retrieves the current session, as well as an event listener for any auth events fired by Supabase client.

The methods for these have changed in Supabase v2, so we'll need to update how they're used.

First, remove the deprecated session() method and use getSession() instead.

Because this new method returns a Promise, we need to call it from within an async function in our useEffect.

Create an async getActiveSession method, and invoke it from within the useEffect. In the AuthProvider component, update the useEffect from this:

useEffect(() => {
  const activeSession = supabase.auth.session();
  setSession(activeSession);
  setUser(activeSession?.user ?? null);
  // ...
}, []);
Enter fullscreen mode Exit fullscreen mode

to this:

useEffect(() => {
  async function getActiveSession() {
    const {
      data: { session: activeSession },
    } = await supabase.auth.getSession();
    setSession(activeSession);
    setUser(activeSession?.user ?? null);
  }
  getActiveSession();
  // ...
}, []);
Enter fullscreen mode Exit fullscreen mode

Next, we need to update the onAuthStateChange handler.

To get the authListener for our effect cleanup, we now need to read it from data.subscription in the returned object:

useEffect(() => {
  // ...
  const {
    data: { subscription: authListener },
  } = supabase.auth.onAuthStateChange((event, currentSession) => {
    setSession(currentSession);
    setUser(currentSession?.user ?? null);
  });
  // ...
  return () => {
    authListener?.unsubscribe();
  };
}, []);
Enter fullscreen mode Exit fullscreen mode

Managing Cookies

The setAuthCookie and getUserByCookie methods have also been deprecated. Recommended solution for managing cookies in Next.js is now the Next.js Auth Helpers library, which we had installed earlier. We'll be using that alongside Next.js middleware (more on that below).

This means that we won't need to manually create or delete a cookie whenever auth state changes, and the /api/auth API routes are no longer required. We can delete pages/api/auth altogether, and remove the fetch call to /api/auth in our onAuthStateChange handler as well:

useEffect(() => {
  // ...
  const { data: authListener } = supabase.auth.onAuthStateChange(
    (event, currentSession) => {
      // fetch('/api/auth', {
      //   method: 'POST',
      //   headers: new Headers({ 'Content-Type': 'application/json' }),
      //   credentials: 'same-origin',
      //   body: JSON.stringify({ event, session: currentSession }),
      // }).then((res) => res.json());
    }
  );
  //...
}, []);
Enter fullscreen mode Exit fullscreen mode

The useEffect in AuthProvider should now look like this:

//...
useEffect(() => {
    async function getActiveSession() {
      const {
        data: { session: activeSession },
      } = await supabase.auth.getSession();
      setSession(activeSession);
      setUser(activeSession?.user ?? null);
    }
    getActiveSession();

    const {
      data: { subscription: authListener },
    } = supabase.auth.onAuthStateChange((event, currentSession) => {
      setSession(currentSession);
      setUser(currentSession?.user ?? null);

      switch (event) {
        case EVENTS.PASSWORD_RECOVERY:
          setView(VIEWS.UPDATE_PASSWORD);
          break;
        case EVENTS.SIGNED_OUT:
        case EVENTS.USER_UPDATED:
          setView(VIEWS.SIGN_IN);
          break;
        default:
      }
    });

    return () => {
      authListener?.unsubscribe();
    };
  }, []);
// ...
Enter fullscreen mode Exit fullscreen mode

Auth Component

With Supabase setup, and AuthProvider updated to use Supabase v2, it's time to build our authentications forms. This is what will replace the Auth component from the (now deprecated) Supabase UI library.

Let's make each form an individual component:

  • Sign In: SignIn.js
  • Sign Up: SignUp.js
  • Reset Password: ResetPassword.js
  • Update Password: UpdatePassword.js

Then, create a parent Auth component that will display the corresponding screen based on the current view:

'use client';

import { useAuth, VIEWS } from 'src/components/AuthProvider';

import ResetPassword from './ResetPassword';
import SignIn from './SignIn';
import SignUp from './SignUp';
import UpdatePassword from './UpdatePassword';

const Auth = ({ view: initialView }) => {
  let { view } = useAuth();

  if (initialView) {
    view = initialView;
  }

  switch (view) {
    case VIEWS.UPDATE_PASSWORD:
      return <UpdatePassword />;
    case VIEWS.FORGOTTEN_PASSWORD:
      return <ResetPassword />;
    case VIEWS.SIGN_UP:
      return <SignUp />;
    default:
      return <SignIn />;
  }
};

export default Auth;
Enter fullscreen mode Exit fullscreen mode

Here, we're checking the value of view from our AuthProvider via the useAuth() hook, and display the corresponding component for the given auth flow. The Auth component also accepts an optional view prop, in case we need to manually override it (as is the case with "Update Password" flow, but more on that later).

The individual forms all follow the same basic structure, which looks like this for Sign In:

'use client';

import { useState } from 'react';
import cn from 'classnames';
import { Field, Form, Formik } from 'formik';
import * as Yup from 'yup';

import { useAuth, VIEWS } from 'src/components/AuthProvider';
import supabase from 'src/lib/supabase-browser';

const SignInSchema = Yup.object().shape({
  email: Yup.string().email('Invalid email').required('Required'),
  password: Yup.string().required('Required'),
});

const SignIn = () => {
  const { setView } = useAuth();
  const [errorMsg, setErrorMsg] = useState(null);

  async function signIn(formData) {
    const { error } = await supabase.auth.signInWithPassword({
      email: formData.email,
      password: formData.password,
    });

    if (error) {
      setErrorMsg(error.message);
    }
  }

  return (
    <div className="card">
      <h2 className="w-full text-center">Sign In</h2>
      <Formik
        initialValues={{
          email: '',
          password: '',
        }}
        validationSchema={SignInSchema}
        onSubmit={signIn}
      >
        {({ errors, touched }) => (
          <Form className="column w-full">
            <label htmlFor="email">Email</label>
            <Field
              className={cn('input', errors.email && touched.email && 'bg-red-50')}
              id="email"
              name="email"
              placeholder="jane@acme.com"
              type="email"
            />
            {errors.email && touched.email ? (
              <div className="text-red-600">{errors.email}</div>
            ) : null}

            <label htmlFor="email">Password</label>
            <Field
              className={cn('input', errors.password && touched.password && 'bg-red-50')}
              id="password"
              name="password"
              type="password"
            />
            {errors.password && touched.password ? (
              <div className="text-red-600">{errors.password}</div>
            ) : null}

            <button
              className="link w-full"
              type="button"
              onClick={() => setView(VIEWS.FORGOTTEN_PASSWORD)}
            >
              Forgot your password?
            </button>

            <button className="button-inverse w-full" type="submit">
              Submit
            </button>
          </Form>
        )}
      </Formik>
      {errorMsg && <div className="text-red-600">{errorMsg}</div>}
      <button
        className="link w-full"
        type="button"
        onClick={() => setView(VIEWS.SIGN_UP)}
      >
        Don&apos;t have an account? Sign Up.
      </button>
    </div>
  );
};

export default SignIn;
Enter fullscreen mode Exit fullscreen mode

Here, we're using Formik to build out the form, and Yup for schema validation (as recommended by Formik). The same pattern is applied for all forms.

Check out the full Formik tutorial for more details on how it works.

Similarly, the remaining auth flows are also implemented:

Check out the linked files for reference.

We'll put all these in the src/components/Auth folder, where index.js is the parent Auth component, and then each individual form is a separate component file:

src/
└── components/
    └── Auth/
        β”œβ”€β”€ index.js
        β”œβ”€β”€ ResetPassword.js
        β”œβ”€β”€ SignIn.js
        β”œβ”€β”€ SignUp.js
        └── UpdatePassword.js
Enter fullscreen mode Exit fullscreen mode

Code for the complete Auth component can be found in GitHub for your reference.

Updating Pages

With the new Auth component in place, it's time to update our page code.

Home

Our Home page will use the original code from src/pages/index.js as the starting point. And there really isn't much to change!

  • We're still using the useAuth hook, and reading view, user and signOut from it
  • Thanks to the new shared layout, we don't need the Layout wrapper anymore
  • The Auth component API is a bit simplified, as it doesn't require the Supabase client to be passed to it anymore (that's done internally in the component)
  • The imports for AuthProvider and Auth will also need to be updated

With all said, the Home page should look something like this:

import Link from 'next/link';

import Auth from 'src/components/Auth';
import { useAuth, VIEWS } from 'src/components/AuthProvider';

export default function Home() {
  const { user, view, signOut } = useAuth();

  if (view === VIEWS.UPDATE_PASSWORD) {
    return <Auth view={view} />;
  }

  if (user) {
    return (
      <div className="card">
        <h2>Welcome!</h2>
        <code className="highlight">{user.role}</code>
        <Link className="button" href="/profile">
          Go to Profile
        </Link>
        <button type="button" className="button-inverse" onClick={signOut}>
          Sign Out
        </button>
      </div>
    );
  }

  return <Auth view={view} />;
}
Enter fullscreen mode Exit fullscreen mode

As before, we are showing the Auth component if no user is found in the current session, or a simple authenticated view with a link to /profile and a Sign Out button otherwise.

Note also that we are rendering the Auth component first if the view is UPDATE_PASSWORD, which means that a user has been redirected to here after clicking the link in Supabase Reset Password email.

NOTE: It's important that this is returned for the UPDATE_PASSWORD view regardless if there's an active user, as the email link passes an access_token along with the URL and Supabase client creates a session with this token. So we're basically in an "authenticated" state during the Update Password flow, and if we check for a user first, the home page will always show the authenticated view without giving our user ability to update their password.

This is essentially the same behaviour as we had in the original solution, but updated to use the new Auth component.

Now, if we try to run the app and go to the Home page in its current state, we'll get an error like this:

Home: Error

As mentioned earlier, in Next 13 pages are Server Components by default when using the app dir. This means that our AuthProvider, which is a Client Component, cannot be consumed within the Home page in its current state.

If we look at the error a bit closer we'll see that the useAuth hook is the culprit:

const { user, view, signOut } = useAuth();
Enter fullscreen mode Exit fullscreen mode

To fix this, we need to make Home a Client Component. As before, add 'use client' at the very top of the src/app/page.js file:

'use client';

import Link from 'next/link';

import Auth from 'src/components/Auth';
import { useAuth, VIEWS } from 'src/components/AuthProvider';

export default function Home() {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now if we run the app again, we should see the Auth component rendered:

Home page: Sign In view

Clicking on "Forgot password" or "Sign Up" text will set view to the corresponding screen (this is implemented internally in the Auth component, by calling setView from the AuthProvider).

Let's go ahead and either Sign In or Sign Up. These flows behave the same as before, and if successful we should see the authenticated part of our Home page:

Home page: Authenticated view

The view rerendered because Home page is a Client Component and is nested within the AuthProvider (also a Client Component). As we had previously done, the onAuthStateChange listener will listen for auth event changes, and update the view in the state, thereby triggering a rerender in relevant components (the Home page in this case).

Profile

For the Profile page, we'll be starting off with src/pages/index.js as our base.

We'll keep this page as a Server Component (default in Next 13), which means that we can call API's and server-side methods directly in the component. So any calls made within getServerSideProps before can now be done directly in the component.

This is also where we use the Supabase Server Component client we had created in src/lib/supabase-server.

Add the following to src/app/profile/page.js:

import Link from 'next/link';
import { redirect } from 'next/navigation';

import createClient from 'src/lib/supabase-server';

export default async function Profile() {
  const supabase = createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect('/');
  }

  return (
    <div className="card">
      <h2>User Profile</h2>
      <code className="highlight">{user.email}</code>
      <div className="heading">Last Signed In:</div>
      <code className="highlight">{new Date(user.last_sign_in_at).toUTCString()}</code>
      <Link className="button" href="/">
        Go Home
      </Link>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Let's break this down a bit.

As noted earlier, the getUserByCookie method was deprecated. We can now get the current user with the getUser() method:

export default async function Profile() {
  const supabase = createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Notice that because getUser() is an async method, we need to make Profile and asynchronous component as well. Keep this in mind as we continue.

Next, we check if there's a valid user, and if there isn't - redirect user back to Home using the redirect function from Next.js. This keeps the Profile page protected, and only allow authenticated users to access it.

NOTE: redirect() can only be called as part of rendering and not as part of event handlers (source). This means you can't use redirect within a button's onClick handler, for example.

Let's run our app, and make sure everything works as expected. If signed in, we should see our Profile page:

Profile

Everything works great!

But let's say we want to add a "Sign Out" button:

Profile

This button will need an onClick handler, which means that we can't use it directly in the Profile as that's a Server Component. We'll need to make this button into a separate Client Component, which can then be imported and used in the Profile.

Let's put this tiny component in src/components/SignOut.js. Upon clicking the button, it'll call the signOut() method from our AuthProvider:

'use client';

import { useAuth } from './AuthProvider';

export default function SignOut() {
  const { signOut } = useAuth();

  async function handleSignOut() {
    const { error } = await signOut();

    if (error) {
      console.error('ERROR signing out:', error);
    }
  }

  return (
    <button type="button" className="button-inverse" onClick={handleSignOut}>
      Sign Out
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then add it to the Profile page:

import SignOut from 'src/components/SignOut';

export default async function Profile() {
  // ...
  return (
    <div className="card">
      {/** ... */}
      <Link className="button" href="/">
        Go Home
      </Link>
      <SignOut />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Now when user presses the button, they'll be signed out and session cleared. But there's one problem.

You see, since Profile is a Server Component, any updates in the AuthProvider state don't cause it to rerender. This also means that any changes in Supabase auth state (ie. user signing out) don't trigger a rerender either, and so our UI doesn't reflect the change in the auth state. We need a different way of handling this, compared to Client Components.

Syncing-up Server and Client states

Our problem is that after a user signs out, our UI (ie. the rendered Server Component) and server state are no longer in sync. In order for them to be in sync, we need to rerender our page.

One way of doing that is to simply reload the page. In fact, if you reload the browser on /profile again, it should redirect you back to Home, as intended. Understandably, we shouldn't be relying solely on users manually refreshing their browser window to update our UI state.

Thanks to the new router in Next 13, we can use the useRouter hook and call the router.refresh() method to trigger a route refresh when there is no longer a valid user in the session.

But how do we know if the session is no longer valid on the server side? A-ha! For this, we'll need to check whether our client and server sessions match.

In the AuthProvider, add the following:

// ...
import { useRouter } from 'next/navigation';

export const AuthProvider = (props) => {
  // ...
  const router = useRouter();

  useEffect(() => {
    // ...
    const {
      data: { subscription: authListener },
    } = supabase.auth.onAuthStateChange((event, currentSession) => {
      if (currentSession?.access_token !== accessToken) {
        router.refresh();
      }
    });
    // ...
  });
  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now, whenever the auth state changes, we're checking if the current session's access_token (on the client) matches the one on the server. If it does not, we trigger a router.refresh() and our UI state will be updated. The server's access token will be passed as the accessToken prop.

So, where should this access token come from? Well, considering that we need to pass it to the AuthProvider and it needs to be done server-side, we need to fetch it in our RootLayout.

Add the following to src/app/layout.js:

import createClient from 'src/lib/supabase-server';

// ...

export default async function RootLayout({ children }) {
  const supabase = createClient();

  const {
    data: { session },
  } = await supabase.auth.getSession();

  const accessToken = session?.access_token || null;

  return (
    <html lang="en">
      {/** ... */}
      <AuthProvider accessToken={accessToken}>{children}</AuthProvider>
      {/** ... */}
    </html>
  );
}
Enter fullscreen mode Exit fullscreen mode

This will read the current server-side session and pass its access_token as the accessToken prop to the AuthProvider. Now the auth listener will have something to compare the client-side session to.

We also don't want our RootLayout to get cached, so we'll need to change the default revalidation behaviour in Next 13 by setting the revalidate option to 0.

Add the following to src/app/layout.js:

// ...
export const revalidate = 0;
Enter fullscreen mode Exit fullscreen mode

This will ensure that every time a new route is loaded, our session data in RootLayout will always be up-to-date.

Now if go back to the Profile page, and click "Sign Out", we should be redirected to Home page.

Adding loading state

You may have noticed that when going to Profile for the first time, it takes a little bit of time to load. Let's exaggerate it by adding a simple sleep util in our Profile and await it:

// ...
const sleep = (ms) =>
  new Promise((resolve) => {
    setTimeout(resolve, ms);
  });

export default async function Profile() {
  // ...
  await sleep(2000);

  const {
    data: { user },
  } = await supabase.auth.getUser();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

This will add a 2-second delay to the getUser() call. Now that is really noticeable. Let's make this better.

In Next 13, there is a new concept of a Loading UI, which adds an instant loading state with the help of React Suspense.

Using it couldn't be any simpler. Just add a loading.js file wherever you'd like to create an instant loading state, and specify the UI to show.

For our Profile, let's add the following to src/app/profile/loading.js:

export default function Loading() {
  return <div className="card h-72">Loading...</div>;
}
Enter fullscreen mode Exit fullscreen mode

Now, when you reload the page (or go to /profile from the home page), you should see the loading UI almost immediately:

Loading UI

And now that we've verified it works as expected, let's not forget to remove that sleep call from our Profile page:

// ...
export default async function Profile() {
  const supabase = createClient();

  const {
    data: { user },
  } = await supabase.auth.getUser();

  if (!user) {
    redirect('/');
  }

  return (
    // ...
  );
}
Enter fullscreen mode Exit fullscreen mode

Flash of unauthenticated state

Almost done! But before we wrap up, there is one other thing left to fix.

Right now, if we have a valid session and reload the Home page, we'll still see a brief flash of unauthenticated state:

Flash of Sign In screen

This happens because on the very first render the Home page (which remember is a Client Component) doesn't yet have any session data from the AuthProvider, so it returns the <Auth> component:

export default function Home() {
  const { initial, user, view, signOut } = useAuth();
  // ...
  return <Auth view={view} />;
}
Enter fullscreen mode Exit fullscreen mode

This, of course, is desired behaviour when there is no user data found. But we know that our session data has a valid user - it just so happens to not be available on the first render, since we fetch that data inside a useEffect in our AuthProvider.

To fix this, we need to be able to differentiate between an "initial" state of our app on the client side - before we check if there's a valid session - and after. This way we'll know whether a valid session truly doesn't exist, or just hasn't had a chance to load yet.

Now, if Home was a Server Component, then we could simply await the result of getSession() (like we do in the RootLayout) and place a Suspense boundary to show a loading state (like we did for the Profile above). But because Home is a Client Component, that won't work.

You see, root-level await is not supported in client-side components, and so we can't "suspend" our Home page until the session data is available like we do with Profile. React team is working on an RFC and a new use hook that will allow us to conditionally wait for data to load, but it's not quite ready yet. Its use (pardon the pun) is also not recommended by Next itself, as it may cause multiple re-renders in Client Components. Considering all that, we'll need to find an alternative way.

There are a few ways we could solve this. For example, we could move the "authenticated" portion of our Home page to a new route altogether (e.g. /home), thereby completely separating the "unathenticated" state. But that would involve a bunch of refactoring of the code we already wrote, and ideally would like to avoid that for this guide. So in the interest of time, we'll go with a bit more "old school" approach.

One simple way to fix this is to add another state variable in our AuthProvider that will simply tell us whether our app is loading for the first time or not. We can set its value to true initially, and once we do the first call to supabase.auth.getSession() we can set it to false, indicating that our app has loaded the data.

In the AuthProvider, create an initial state, and make sure its value is provided:

// ...
export const AuthProvider = (props) => {
  const [initial, setInitial] = useState(true);
  // ...
  useEffect(() => {
    async function getActiveSession() {
      const {
        data: { session: activeSession },
      } = await supabase.auth.getSession();
      // ...
      setInitial(false);
    }
    getActiveSession();
    // ...
  }, []);
  // ...
  const value = useMemo(() => {
    return {
      initial,
      session,
      user,
      view,
      setView,
      signOut: () => supabase.auth.signOut(),
    };
  }, [initial, session, user, view]);

  return <AuthContext.Provider value={value} {...rest} />;
}
Enter fullscreen mode Exit fullscreen mode

Now we can read initial using the useAuth hook in our Home page, and while it's value is true, show our loading state instead of returning the <Auth> component:

// ...
export default function Home() {
  const { initial, user, view, signOut } = useAuth();

  if (initial) {
    return <div className="card h-72">Loading...</div>;
  }
  // ...
  return <Auth view={view} />;
}
Enter fullscreen mode Exit fullscreen mode

And now if we reload our Home page, we should see the same kind of loading state that we have in Profile:

Home page loading

This is a quick and easy way of dealing with this problem, but by no means the only way. Ideally we'd like to use Suspense for this, but until React lands on a more established pattern for it in client-side components, we're not going to focus on it too much. For the time being, this solves our immediate problem in this scenario.

Wrap Up

Well, this about wraps it up. If you've made it this far - thank you for reading! Hopefully this guide gives you a good starting point for using Supabase in your Next 13 project, or at the very least points you in the right direction.

Make sure to give the new Next.js Beta Docs a read as well, as I've found them to be an invaluable resource for learning some of these new paradigms coming to the world of React.

EDIT: The new Next.js Docs have now been updated. Definitely give them a read if you haven't yet.

Happy coding!

Reference Materials

EDIT: Both React and Next.js docs have since been released.

πŸ’– πŸ’ͺ πŸ™… 🚩
mryechkin
Mykhaylo Ryechkin πŸ‡ΊπŸ‡¦

Posted on January 25, 2023

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

Sign up to receive the latest update from our blog.

Related