Setting up Authentication/Authorization in NextJS app router with Supabase

Intro

I took the time to write this blog to help people (and myself in the future) setting up auth/authorization in their NextJS/Supabase projects.

After the NextJS transition to the app router, the paradigm shift has left a lot of people wondering on how to do things correctly. React Server Components, "use client" and the damned hydration errors among others made a lot of devs wonder if they should transition to other solutions (Sveltekit, HTMX(!!), Vue, Angular).

For the sake of this tutorial, I am assuming you already have a NextJS project set up together with a Supabase instance. If you don't, here are some links to help you get started:

NextJS Getting started: Link

Supabase Getting started: Link

Setting up

The Supabase team has been fast in adapting to the app router and created the new @supabase-ssr package migrating from their older @auth-helpers. So let's get started!

Open the terminal in your application and type:

npm install @supabase/ssr @supabase/supabase-js

This will install the dependencies we are going to use for our example.

Creating the files

On the root of you application create a utils folder and inside of it create a supabase folder. We will then create the files we will need inside. Create the following 3 files inside the supabase folder:

  • client.js
  • server.js
  • middleware.js

This will help us create Supabase clients to use in the server or the client depending on our needs. We will also create a middleware that runs between pages in the server to refresh the user token and protect specific pages of our app. Thankfully the packages we installed earlier provide helpful functions to make our implementation easier.

After that we will be creating an api route handler to exchange a code that Supabase sends us for the user session. But let's keep it simple for now.

If you haven't already, get the Supabase environmental variables and put them in your project env files. In your Supabase dashboard, locate the Settings page and then click on the "API" section under the Configuration list.

Your .env.local file should look something like this:

NEXT_PUBLIC_SUPABASE_URL=***YOUR_SUPABASE_DB_URL***
NEXT_PUBLIC_SUPABASE_ANON_KEY=***YOUR_SUPABASE_ANON_KEY***

Let's get started implementing the code in our files!

client.js

This will be used when we need to connect to our Supabase database from client components.

import { createBrowserClient } from "@supabase/ssr";

export const createClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );

server.js

This will be used when we need to connect to our Supabase database from server components/api routes/server actions. NextJS sends an error if we try to set or remove the cookies from a server component like shown in the code below. We do not need to worry about it since we will be doing all that from our middleware function in the next steps.

import { createServerClient } from "@supabase/ssr";

export const createClient = (cookieStore) => {
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        get(name) {
          return cookieStore.get(name)?.value;
        },
        set(name, value, options) {
          try {
            cookieStore.set({ name, value, ...options });
          } catch (error) {
              console.log(error)
          }
        },
        remove(name, options) {
          try {
            cookieStore.set({ name, value: "", ...options });
          } catch (error) {
              console.log(error)
          }
        },
      },
    }
  );
};

The cookieStore arguement comes from the next/headers. On your server component or server-side code you can use it like this

import { cookies } from "next/headers";
import { createClient } from "@/utils/supabase/server";

const ExampleServerComponent = () => {
  const cookieStore = cookies();
  const supabase = createClient(cookieStore);
}

middleware.js

This is the middleware supabase file that we will use to refresh the logged in user's token while also letting us set up protected routes in our NextJS middleware.

import { createServerClient } from "@supabase/ssr";
import { NextResponse } from "next/server";

export const createClient = (request) => {
  // Create an unmodified response
  let response = NextResponse.next({
    request: {
      headers: request.headers,
    },
  });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
    {
      cookies: {
        get(name) {
          return request.cookies.get(name)?.value;
        },
        set(name, value, options) {
          // If the cookie is updated, update the cookies for the request and response
          request.cookies.set({
            name,
            value,
            ...options,
          });
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          });
          response.cookies.set({
            name,
            value,
            ...options,
          });
        },
        remove(name, options) {
          // If the cookie is removed, update the cookies for the request and response
          request.cookies.set({
            name,
            value: "",
            ...options,
          });
          response = NextResponse.next({
            request: {
              headers: request.headers,
            },
          });
          response.cookies.set({
            name,
            value: "",
            ...options,
          });
        },
      },
    }
  );

  return { supabase, response };
};

Now we will use this file on the NextJS middleware special file. On the root of your application create a middleware.js file. This is a special NextJS file that will run before each request to a resource in our app is completed. More info on this special file can be found in the NextJS docs.

Once you've created the file, copy the following code

import { NextResponse } from "next/server";
import { createClient } from "@/utils/supabase/middleware";

const protectedRoutes = ["/dashboard"];

export async function middleware(request) {
  try {
    const { supabase, response } = createClient(request);

    const { data, error } = await supabase.auth.getUser();
    
    const notAuthenticated = error || !data?.user;

    //If user is trying to navigate to a protected route without being logged in,
    //redirect him to login
    if (notAuthenticated && protectedRoutes.includes(request.nextUrl.pathname)) {
      const response = NextResponse.redirect(new URL("/login", request.url));
      return response;
    }

    return response;
  } catch (e) {
    return NextResponse.next({
      request: {
        headers: request.headers,
      },
    });
  }
}

export const config = {
  matcher: [
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

The above code creates a server client inside the NextJS middleware. We use the built in getUser() function to get the logged in user data. If the function returns an error or no user data, we store it in the notAuthenticated variable. We proceed to make the check, if we don't have an authenticated user and the requested url is in our protected routes, we redirect the user to the login page.

Final step

The main functionality of our authentication/authorization flow is ready! Now there is only one last step/file to do in order to finalize. Supabase sends a code that we need to exchange in an api route handler for the user session. Let's create a file in the following route app/auth/callback/route.js

Once created, copy the following code inside the file:

import { cookies } from "next/headers";
import { NextResponse } from "next/server";
import { createClient } from "@/utils/supabase/server";

export async function GET(request) {
  const { searchParams, origin } = new URL(request.url);
  const code = searchParams.get("code");
  const next = "/dashboard";

  if (code) {
    const cookieStore = cookies();
    const supabase = createClient(cookieStore);
    const { error } = await supabase.auth.exchangeCodeForSession(code);
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  // return the user to an error page with instructions
  return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}

This route handler gets the code that is sent in the query parameters from Supabase when we log in using their built in functions. After that we exchange the code for the user session and server side. Change the next value to the url you want to redirect the user to.

That's it!

I'll provide a small example of a login page that you can use that uses a magic link to log in our user. An email is sent to the user trying to login that redirects to our code exchange route! Create a file in app/login.js and copy the following code

"use client";
import { createClient } from "@/utils/supabase/client";
import { useState } from "react";
import Image from "next/image";
import Link from "next/link";

const Page = () => {
  const [email, setEmail] = useState("");
  const [loading, setLoading] = useState(false);
  const [hasSentMagicLink, setHasSentMagicLink] = useState(false);
  const supabase = createClient();

  const signInWithEmail = async (e) => {
    setLoading(true);
    e.preventDefault();
    const { data, error } = await supabase.auth.signInWithOtp({
      email: email,
      options: {
        emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
      },
    });

    if (!error) setHasSentMagicLink(true);
    if (error) alert(error.message);
    setLoading(false);
  };

  return (
    <div className="min-h-screen relative flex flex-col justify-center overflow-hidden">
      <div className="mt-8 sm:mx-auto sm:w-full sm:max-w-md z-20 relative">
        <div className="bg-white z-20 py-8 px-4 shadow sm:rounded-lg sm:px-10">
          <form className="space-y-6">
            <div className="flex flex-col gap-1">
              <div className="text-transparent bg-clip-text bg-gradient-to-r from-[#be4bdb]  to-[#228be6] font-medium text-lg">
                Email login
              </div>
              <div className="text-sm text-zinc-700 font-light">
                Type your email and you will receive a magic link to login.
              </div>
            </div>
            <div>
              <input
                id="email"
                name="email"
                type="email"
                autoComplete="email"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="appearance-none rounded-md relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 focus:outline-none focus:ring-[#be4bdb] focus:border-[#be4bdb] focus:z-10 sm:text-sm"
                placeholder="Enter your email address"
              />
            </div>
            <div>
              {hasSentMagicLink ? (
                <div className="text-center text-transparent bg-clip-text bg-gradient-to-r from-[#be4bdb]  to-[#228be6]">
                  We&apos;ve sent you a magic link to login. Check your email!
                </div>
              ) : (
                <button
                  disabled={loading || !isValidEmail(email)}
                  onClick={signInWithEmail}
                  type="submit"
                >
                  Sign in
                </button>
              )}
            </div>
          </form>
          </div>
        </div>
      </div>
  );
};

export default Page;

End notes

I tried to setup this flow for a web app I was building and found out that the docs are a bit all over the place and it's natural to happen since the transition to the app router requires for all the documentation that was written using the old pages router to adapt to the new paradigm.

A lot of React/NextJS libraries and services are adapting to the changes and implementing these services in our own apps should be getting easier day by day.

I hope you found this post helpful. Thanks!