HKS

How to Implement Google Sign-In in Next.js with Supabase Auth

Learn how to integrate Google Sign-in functionality into your Next.js app using Supabase Auth.

Author avatar

Hemanta Sundaray

Published on Sunday, July 21st, 2024

If you want to integrate "Sign in with Google" in your Next.js app using Supabase Auth, you're in the right place. This comprehensive guide covers everything you need, from creating a Supabase project and configuring Google Cloud for OAuth to setting up your Next.js project and implementing robust authentication and authorization logic.

In this guide, you'll learn how to:

  • Set up a Supabase project and configure Google Cloud for OAuth
  • Implement Google Sign-in in your Next.js application
  • Access user information inside both client and server components
  • Protect routes using middleware
  • Assign roles to users using Supabase auth hooks
  • Create and manage a users table in the public schema

Let's get started!

Create a Supabase Project

  • Go to supabase.com/dashboard/projects (sign in if you have an account, or create one if you don't).

  • Click on New project.

  • Give your project a name, choose a database password, select a region, and then click on Create new project.

  • The project setup may take a minute or two to complete. Once the setup is finished, scroll down the page to find the Project API and JWT Settings sections.

    From the Project API section, copy the Project URL and API Key. From the JWT Settings section, copy the JWT Secret. Save these three values somewhere safe - we'll need them later in our setup process.

    You'll notice that the API Key is tagged with anon and public. This anon key (short for anonymous) is a public key that's safe to use in a browser, but only if you've enabled RLS (row-level security) for your tables and configured appropriate policies.

Note

When you create a Supabase project, Supabase automatically deploys an instance of the Auth API server and injects your database with the required auth schema. This Auth API server is responsible for:

  • Validating, issuing, and refreshing JSON Web Tokens (JWTs).
  • Serving as an intermediary between your app and the auth information in the database.
  • Communicating with external providers for Social Login and SSO (Single Sign-On).

To view the auth schema in the Supabase dashboard:

  • Click on Database in the sidebar navigation.
  • You'll see a dropdown menu to select a schema. Choose auth from this dropdown.
  • You'll notice a list of tables appears. These are the tables that store user information, sessions, identities and other authentication-related data

It's crucial to understand that the auth schema in Supabase is managed internally and is read-only through the dashboard. This means that your application code cannot directly access or modify the auth schema tables.

But what if you want to keep track of user information, for example, to display a list of users on a page accessible only to administrators? In such cases, you must create your own user table in the public schema, which I'll guide you through later in this guide.

You can then link the public.users table to the auth.users table using the users' UUIDs as foreign keys. This approach allows you to store and manage user data while maintaining the integrity and security of Supabase's core authentication system.

Configure Google Cloud for OAuth

  • Go to the Google Cloud Console.
  • On the main navigation bar at the top, to the left of the search input, there is a dropdown menu. Click on the dropdown menu to open a pop-up window with all of your projects listed. In the pop-up window, click on NEW PROJECT.
  • Enter a project name and click CREATE. It’ll take half a minute or so to create the project. Once the project is created, select it.
  • Navigate to APIs & Services > OAuth consent screen.
  • Choose External as the user type (this will make your project accessible to anyone with a Google account) and click CREATE.

  • Under App information, enter the App name and the user support email. Under Authorized domains, enter the Supabase Project URL (which you had copied earlier). Under Developer contact information, enter your email address. Then click SAVE AND CONTINUE.

    An authorized domain is a domain that you explicitly allow to use your OAuth credentials. By entering the Supabase Project URL as an authorized domain, you ensure that only requests originating from the Supabase Project URL can use your OAuth Client ID and Client Secret to authenticate users. This helps prevent unauthorized use of your OAuth credentials.

Scopes

On the Scopes page, you'll see the following information:

  • Non-sensitive scopes: Permissions that your app requests which are generally safe and don't require extensive review by Google.
  • Sensitive scopes: Permissions that request access to more private user data.
  • Restricted scopes: Permissions that request access to highly sensitive user data.

If your app doesn't require access to sensitive or restricted data, you can proceed without adding any scopes. Click SAVE AND CONTINUE.

Test users

On the Test users page, you'll see the following information:

  • Test users: While publishing status is set to Testing, only test users are able to access the app. Allowed user cap prior to app verification is 100, and is counted over the entire lifetime of the app.

If you don't need to add any specific test users at this point, you can proceed by clicking SAVE AND CONTINUE.

Summary

On the Summary page, review the information to ensure everything is correct. Click BACK TO DASHBOARD.

OAuth Client ID

Navigate to APIs & Services > Credentials. Click on Create Credentials and select OAuth client ID.

On the Create OAuth Client ID page, follow the steps below to fill out the fields:

  • Application Type: Select Web application.
  • Name: Enter a name for your OAuth 2.0 client. This name is only used to identify the client in the console and will not be shown to end users.
  • Authorized JavaScript origins: Add the origins from which your app is allowed to access the Google APIs. As we are developing locally, add http://localhost:3000.
  • Authorized Redirect URIs: Add the Callback URL from your Supabase dashboard.
  • Click CREATE to generate your Client ID and Client Secret. Copy both the Client ID and the Client Secret and save them somewhere safe; we will need them in the next step.

How to locate the Callback URL (OAuth) in Supabase dashboard

To find the Callback URL in your Supabase dashboard, follow these steps:

  • On the sidebar navigation, navigate to Authentication.
  • Click Providers under the CONFIGURATION section.
  • Click Google in the list of providers. Scroll down slightly, and you'll see the Callback URL displayed.

Next, return to your Supabase dashboard to continue the setup.

Enable Google sign-in

  • On the sidebar navigation, navigate to Authentication.
  • Click on Providers under the CONFIGURATION section.
  • Find Google in the list and click on it.
  • Toggle Enable Sign In with Google to on.
  • Enter the Client ID and Client Secret you obtained from Google Cloud.
  • Click Save to apply the changes

Add Redirect URL

  • On the sidebar navigation, navigate to Authentication.
  • Click on URL Configuration under the CONFIGURATION section.
  • In the Redirect URLs section, add http://localhost:3000/** .

By adding http://localhost:3000/**, you ensure that after a user has authenticated using Google sign-In, they are redirected back to your local development server (running at http://localhost:3000).

Set up a Next.js Project

Create a Next.js project by using create-next-app:

npx create-next-app@latest supabase-auth-nextjs-demo

Note: I’ve named the project supabase-auth-nextjs-demo, but you can choose any name you prefer.

Once the project is created, navigate to the project directory.

Install packages

We'll need to install several packages for our project. Let's go through them:

Install shadcn/ui:

shadcn/ui is a collection of re-usable components that we'll use in our project. To set it up, run the following command:

npx shadcn-ui@latest init

Follow the prompts to configure shadcn/ui according to your preferences.

We'll be using the Button, Dropdown, and Toast components. Install them with these commands:

npx shadcn-ui@latest add button
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add toast

Install Supabase packages

We'll need the Supabase client library to interact with our Supabase backend:

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

Install client-only and server-only packages

npm install client-only server-only

These packages are useful for ensuring that certain modules are only used in their intended environment, client or server. For example, if you try to use a module marked as client-only in a server environment, you'll get an error, and vice versa.

Install JWT decoding libraries

npm install jose jwt-decode

These libraries are necessary to decode the JWT and access the user role. We'll be using Supabase auth hooks to add user roles inside the JWT, so we need libraries to decode them. jose is used for server-side decoding, while jwt-decode is used for client-side decoding.

Install icon libraries

npm install lucide-react react-icons

Configure Environment Variables

Create a .env.local file in the root directory of your project, and add the following environment variables:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_JWT_SECRET=your_supabase_jwt_secret

Replace your_supabase_project_url, your_supabase_anon_key, and your_supabase_jwt_secret with the values you copied earlier from your Supabase project.

If you need to find these values again, click Project Settings in the sidebar navigation of your Supabase dashboard. Under the CONFIGURATION section, click API. Here you'll find the Project URL, the API Key and the JWT Secret.

Set up Supabase clients

We need to set up two different Supabase clients.

Create a new folder named supabase inside the lib folder, and inside supabase, create two files: client.ts and server.ts.

In client.ts, add the following code:

@/lib/supabase/client.ts
import "client-only"
 
import { createBrowserClient } from "@supabase/ssr"
 
export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

In server.ts, add the following code:

@/lib/supabase/server.ts
import "server-only"
 
import { cookies } from "next/headers"
import { createServerClient } from "@supabase/ssr"
 
export function createClient() {
  const cookieStore = cookies()
 
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

You might be wondering why we need two separate clients for Supabase Auth. The reason is that we use different methods to access Supabase depending on where we're making the call:

  • The createClient function in client.ts is used to access Supabase inside client components.
  • The createClient function in server.ts is used to access Supabase inside server components, server actions, and route handlers.

Set up middleware

Middleware in Next.js allows you to run code before a request is completed. In our case, we'll use it to handle authentication and authorization. Let's set it up:

Root middleware

First, create a middleware.ts file at the root of your project:

@/middleware.ts
import { type NextRequest } from "next/server"
 
import { updateSession } from "@/lib/supabase/middleware"
 
export async function middleware(request: NextRequest) {
  return await updateSession(request)
}
 
export const config = {
  matcher: ["/protected", "/signin", "/admin/:path*"],
}

The matcher array specifies which routes the middleware should run on. In this case:

  • /protected: Ensures only authenticated users can access this route
  • /signin: Handles redirects for already authenticated users
  • /admin/:path*: Protects all routes under /admin, ensuring only users with admin roles can access them

Supabase middleware

Now, create a middleware.ts file inside the lib/supabase folder:

@/lib/supabase/middleware.ts
import { NextResponse, type NextRequest } from "next/server"
import { createServerClient } from "@supabase/ssr"
 
import { getUserRole } from "@/lib/get-user-role"
 
export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })
 
  // Create a Supabase client
  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) =>
            request.cookies.set(name, value)
          )
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )
 
  // Get the current user from Supabase
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  // Get the user's role using the custom getUserRole function
  const role = await getUserRole()
 
  // Redirect non-admin users trying to access admin pages to the home page
  if (
    user &&
    role !== "admin" &&
    request.nextUrl.pathname.startsWith("/admin")
  ) {
    const url = request.nextUrl.clone()
    url.pathname = "/"
    return NextResponse.redirect(url)
  }
 
  // Redirect unauthenticated users to sign-in page
  if (
    !user &&
    !request.nextUrl.pathname.startsWith("/signin") &&
    !request.nextUrl.pathname.startsWith("/auth")
  ) {
    const url = request.nextUrl.clone()
    url.pathname = "/signin"
    url.searchParams.set("next", request.nextUrl.pathname)
    return NextResponse.redirect(url)
  }
 
  // Redirect authenticated users attempting to access the sign-in page to the home page
  if (user && request.nextUrl.pathname.startsWith("/signin")) {
    const url = request.nextUrl.clone()
    url.pathname = "/"
    return NextResponse.redirect(url)
  }
 
  return supabaseResponse
}

This middleware file handles several key aspects of our authentication flow:

  • It creates a Supabase client.
  • It checks the user's authentication status and role.
  • It manages redirects based on the user's authentication status and the route they're trying to access.

Here's a breakdown of the redirect logic:

  • If an unauthenticated user tries to access any page mentioned in the matcher array, they are redirected to the sign-in page. While redirecting, we also set the next parameter. This next parameter stores the pathname of the page the user was trying to access. After successful authentication, we use this parameter to redirect the user back to their intended destination.
  • If a signed-in user tries to access the sign-in page, we redirect them to the home page. This is because the user is already authenticated, so they don't need to go to the sign-in page anymore.
  • Finally, if a signed-in user tries to access an admin page but does not have the admin role, they are redirected to the home page. This ensures that only users with the appropriate permissions can access sensitive admin areas of the application.

By implementing these redirects, we maintain proper access control throughout the application, ensuring users are always directed to the appropriate pages based on their authentication status and roles.

Notice that in the middleware, we use the getUserRole() function to determine the user's role. Let's create this function.

Create a new file named get-user-role.ts inside the lib folder:

@/lib/get-user-role.ts
import "server-only"
 
import { JWTPayload, jwtVerify } from "jose"
 
import { createClient } from "@/lib/supabase/server"
 
// Extend the JWTPayload type to include Supabase-specific metadata
type SupabaseJwtPayload = JWTPayload & {
  app_metadata: {
    role: string
  }
}
 
export async function getUserRole() {
  // Create a Supabase client for server-side operations
  const supabase = createClient()
 
  // Retrieve the current session
  const {
    data: { session },
  } = await supabase.auth.getSession()
 
  let role
 
  if (session) {
    // Extract the access token from the session
    const token = session.access_token
 
    try {
      // Encode the JWT secret for verification
      const secret = new TextEncoder().encode(process.env.SUPABASE_JWT_SECRET)
 
      // Verify the JWT token and extract the payload
      const { payload } = await jwtVerify<SupabaseJwtPayload>(token, secret)
 
      // Extract the role from the app_metadata in the payload
      role = payload.app_metadata.role
    } catch (error) {
      console.error("Failed to verify token:", error)
    }
  }
 
  return role
}

This function does the following:

  • It creates a Supabase client.
  • It retrieves the current session using supabase.auth.getSession().
  • If a session exists, it extracts the access token (JWT).
  • It then prepares to verify the JWT token. The JWT secret is encoded using TextEncoder().encode(process.env.SUPABASE_JWT_SECRET). This step is necessary because the jwtVerify function from the jose library expects the secret to be in a specific format - a Uint8Array.
  • It verifies the JWT token using the encoded SUPABASE_JWT_SECRET.
  • After verification, it extracts the role from the app_metadata in the token payload.
  • Finally, it returns the user's role.

Assign roles to users

After setting up our middleware, we need to address how roles are assigned to users. This is where the concept of custom claims comes into play.

Custom claims are essentially special attributes that we can attach to a user. In our case, we want to attach a role, so that we can use this role to control access to special areas of our application.

These custom claims are implemented using a Custom Access Token Auth Hook. Auth hooks are Postgres functions that we can use to alter the default Supabase Auth flow. We'll use the following function to add a role field to the JWT:

create or replace function public.set_user_role(event jsonb)
returns jsonb
language plpgsql
as $$
declare
  claims jsonb;
  user_email text;
begin
  -- Log the entire event object
  RAISE LOG 'Full event object: %', event;
 
  -- Get the user's email
  user_email := event->>'email';
 
  -- Log the user's email
  RAISE LOG 'User email: %', user_email;
 
  claims := coalesce(event->'claims', '{}'::jsonb);
 
  -- Check if 'app_metadata' exists in claims
  if jsonb_typeof(claims->'app_metadata') is null then
    claims := jsonb_set(claims, '{app_metadata}', '{}');
  end if;
 
  -- Set a claim of 'admin' if the email matches, otherwise 'regular'
  if user_email = 'rawgrittt@gmail.com' then
    claims := jsonb_set(claims, '{app_metadata, role}', '"admin"');
    -- Log the role being set
    RAISE LOG 'Setting role to admin for email: %', user_email;
  else
    claims := jsonb_set(claims, '{app_metadata, role}', '"regular"');
    -- Log the role being set
    RAISE LOG 'Setting role to regular for email: %', user_email;
  end if;
 
  -- Update the 'claims' object in the original event
  event := jsonb_set(event, '{claims}', claims);
 
  -- Log the final event object
  RAISE LOG 'Final event object: %', event;
 
  return event;
end;
$$;
 
-- Set permissions for the function
grant usage on schema public to supabase_auth_admin;
grant execute on function public.set_user_role to supabase_auth_admin;
revoke execute on function public.set_user_role from authenticated, anon, public;

This function does the following:

  • It checks the user's email address.
  • If the email is rawgrittt@gmail.com, it sets the role to admin.
  • For all other email addresses, it sets the role to regular.
  • The role is set inside the app_metadata object, which is part of the JWT claims.

We've used RAISE LOG statements throughout the function. These are helpful for debugging purposes, allowing you to study the structure of the event object passed to the function and troubleshoot if any errors occur.

To find these logs in the Supabase dashboard, navigate to Logs on the sidebar navigation and then click Postgres under the INFRASTRUCTURE LOGS section.

Notice the last three lines of code in the function above. These lines are crucial for setting up the correct permissions for our function:

  • We grant the supabase_auth_admin role permission to use the public schema.
  • We grant the supabase_auth_admin role permission to execute our set_user_role function.
  • We revoke execute permissions from the authenticated, anon, and public roles.

The reason for revoking permissions is to enhance security. By default, the public role has access to functions created in the public schema, and both anon and authenticated roles inherit permissions from the public role. By revoking these permissions, we ensure that our role-setting function is not accessible via Supabase Serverless APIs or by regular users. This prevents potential misuse or unauthorized manipulation of user roles.

To implement this function:

  • Navigate to SQL Editor in the sidebar navigation of the Supabase dashboard.
  • Paste the entire function (including the permission grants and revokes) into the editor.
  • Click Run to create the function and set the permissions.

Finally, to enable the custom claims:

  • In the Supabase dashboard, navigate to Authentication in the sidebar navigation.
  • Click Hooks under the CONFIGURATION section.
  • Enable Custom Access token (JWT) Claims hook.
  • Select the set_user_role function.

By setting up this hook, Supabase will add the role property to the JWT before releasing it, ensuring that each user's token contains their assigned role.

Create a useUser custom hook

Now, we will create a custom hook that we will use inside client components to get user details. At the root of the project, create a directory named hooks and inside hooks, create a file named use-user.ts:

@/hooks/use-user.ts
import { useEffect, useState } from "react"
import { AuthError, Session, User } from "@supabase/supabase-js"
import { jwtDecode } from "jwt-decode"
import type { JwtPayload } from "jwt-decode"
 
import { createClient } from "@/lib/supabase/client"
 
type SupabaseJwtPayload = JwtPayload & {
  app_metadata: {
    role: string
  }
}
 
export function useUser() {
  const [user, setUser] = useState<User | null>(null)
  const [session, setSession] = useState<Session | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<AuthError | null>(null)
  const [role, setRole] = useState<string | null>(null)
  const supabase = createClient()
 
  useEffect(() => {
    async function fetchUser() {
      try {
        const {
          data: { session },
          error,
        } = await supabase.auth.getSession()
        if (error) throw error
 
        if (session) {
          setSession(session)
          setUser(session.user)
          const decodedJwt = jwtDecode<SupabaseJwtPayload>(session.access_token)
          setRole(decodedJwt.app_metadata.role)
        }
      } catch (error) {
        setError(error as AuthError)
      } finally {
        setLoading(false)
      }
    }
    fetchUser()
  }, [])
 
  return { loading, error, session, user, role }
}

Create a users Table

In most applications, you'll want to access user data for various purposes. Even though we've taken care of authentication and authorization, we haven't yet saved the users' data in a way that's easily accessible to our application.

As discussed earlier, the user data inside the auth.users table is not directly accessible to our application. To solve this, we need to create our own table in the public schema.

In the Supabase dashboard, navigate to SQL Editor on the sidebar navigation. Then paste the following function in the editor and click Run.

CREATE TABLE public.users (
  id UUID PRIMARY KEY REFERENCES auth.users(id) NOT NULL,
  name TEXT NOT NULL,
  email TEXT NOT NULL UNIQUE,
  role TEXT NOT NULL DEFAULT 'regular'
);

This will creates a table named users in the public schema with four columns:

  • id: A UUID that serves as the primary key and references the id column in the auth.users table.
  • name: The user's full name.
  • email: The user's email address (must be unique).
  • role: The user's role in the application (defaults to 'regular').

The REFERENCES auth.users(id) clause in the id column definition is important to understand. It sets up a foreign key relationship between our public.users table and the auth.users table. This relationship ensures that every id in our public.users table corresponds to a valid user in the auth.users table.

Now that we have created the table, we'll use a concept called database triggers to automatically populate data inside the users table in the public schema.

What are database triggers?

Database triggers are special functions that automatically run when certain events occur in a database. Triggers can be set to run either before or after the triggering event.

In the SQL Editor, paste the code below and click Run to create the trigger:

CREATE OR REPLACE FUNCTION public.create_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.users (id, name, email, role)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data->>'full_name',
    NEW.email,
    CASE
      WHEN NEW.email = 'rawgrittt@gmail.com' THEN 'admin'
      ELSE 'regular'
    END
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
 
CREATE TRIGGER on_auth_user_created
  AFTER INSERT ON auth.users
  FOR EACH ROW EXECUTE FUNCTION public.create_new_user();

Let's break down what this code does:

  • We create a function called create_new_user() that will be executed by our trigger.
  • Inside this function, we insert a new row into our public.users table using data from the newly inserted auth.users row (represented by NEW).
  • We use NEW.raw_user_meta_data->>'full_name' to extract the full name from the metadata.
  • We set the role to admin for a specific email address (in this case, rawgrittt@gmail.com) and regular for all others.
  • We create a trigger named on_auth_user_created that executes our function after each insert on the auth.users table.

With this trigger in place, every time a new user signs up or is created in the auth.users table, a corresponding entry will automatically be inserted into our public.users table. This ensures that our application always has access to the latest user data in a format that we can easily query and manipulate.

Enable RLS on the users table

Note

Every table created in the public schema should have Row Level Security (RLS) enabled.

RLS allows you to control which users can access and modify specific rows in a table based on their authentication status and/or role or other criteria. Since the users table contains sensitive information, we'll enable RLS and create a policy that only allows logged-in users with an admin role to query and manipulate its data.

In the Supabase dashboard, navigate to the SQL Editor on the sidebar navigation, and run the following command:

ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;

Next, create the policy by running this command:

CREATE POLICY "Allow only admin access to the users table"
ON public.users
FOR ALL
USING (
  current_setting('request.jwt.claims', true)::jsonb #>> '{app_metadata, role}' = 'admin'
);

Let's break down each part of this policy:

  • CREATE POLICY: Creates a policy named Allow only admin access to the users table.

  • ON public.users: Specifies that the policy applies to the users table within the public schema.

  • FOR ALL: Means this policy applies to all operations on the table, including SELECT, INSERT, UPDATE, and DELETE.

  • USING Clause: Defines the actual condition that must be satisfied for a row to be accessible. current_setting('request.jwt.claims', true) fetches the current JWT claims from the session settings. The true parameter indicates that if the setting is not found, it should return NULL instead of throwing an error. ::jsonb casts the fetched JWT claims to a JSONB (Binary JSON) data type. #>> '{app_metadata, role}' operator extracts the value from the JSONB data at the specified path. In this case, it navigates to the app_metadata section and then to the role key within the JWT claims.

  • = 'admin' checks if the extracted role is equal to admin. If it is, the condition is satisfied, and the user is granted access to the row.

The policy essentially ensures that only users whose JWT claims include a role of admin can perform any operation on the users table.

To verify that the RLS policy works as expected, sign in using a non-admin email (after you have created all the components and pages). Then query the users table from any page except the admin page. You'll receive an empty array in response.

Create components

In a typical app, you'll find a main navigation bar with navigation links and a sign-in button. This is the structure we're going to follow.

We'll create a main navigation bar component with the following links:

  • Home
  • Client
  • Server
  • Protected
  • Admin

These links are carefully planned to cover common authentication and authorization use cases in a Next.js app:

  • Accessing signed-in user information in client components
  • Accessing signed-in user information in server components
  • Protecting routes accessible only to signed-in users
  • Protecting routes accessible only to signed-in users with specific roles (e.g., admin)

Let's start by defining our components.

Create a file named main-nav.tsx in the components folder:

@/components/main-nav.tsx
import { NavItem } from "@/components/nav-item"
import { UserAccountNav } from "@/components/user-account-nav"
 
export function MainNav() {
  return (
    <nav className="flex h-16 items-center justify-between border px-4">
      <div className="flex space-x-4">
        <NavItem href="/">Home</NavItem>
        <NavItem href="/client">Client</NavItem>
        <NavItem href="/server">Server</NavItem>
        <NavItem href="/protected">Protected</NavItem>
        <NavItem href="/admin">Admin</NavItem>
      </div>
      <UserAccountNav />
    </nav>
  )
}

Create a file named nav-item.tsx in the components folder:

@/components/nav-items.tsx
"use client"
 
import Link from "next/link"
import { usePathname } from "next/navigation"
 
import { cn } from "@/lib/utils"
 
type NavItemProps = {
  href: string
  children: React.ReactNode
}
 
export function NavItem({ href, children }: NavItemProps) {
  const pathname = usePathname()
  const isActive = pathname === href
 
  return (
    <Link
      href={href}
      className={cn("text-muted-foreground text-sm", {
        "text-secondary-foreground": isActive,
      })}
    >
      {children}
    </Link>
  )
}

UserAccountNav component

Create a file named user-account-nav.tsx in the components folder:

@/components/user-account-nav.tsx
import React from "react"
import { FaUser } from "react-icons/fa6"
 
import { createClient } from "@/lib/supabase/server"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { SignInButton } from "@/components/signin-button"
import { SignOutButton } from "@/components/signout-button"
 
export async function UserAccountNav() {
  const supabase = createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  if (!user) {
    return <SignInButton />
  }
 
  return (
    <div className="w-max space-x-2">
      <DropdownMenu>
        <DropdownMenuTrigger className="flex items-center space-x-1">
          <div className="bg-border grid size-7 place-items-center rounded-full">
            <FaUser className="text-muted-foreground" />
          </div>
        </DropdownMenuTrigger>
        <DropdownMenuContent align="end">
          <div className="flex items-center justify-start gap-2 p-2">
            <div className="flex flex-col space-y-1 leading-none">
              {user.email && (
                <p className="text-muted-foreground w-[200px] truncate text-sm">
                  {user.email}
                </p>
              )}
            </div>
          </div>
          <DropdownMenuSeparator />
          <DropdownMenuItem className="cursor-pointer">
            <SignOutButton />
          </DropdownMenuItem>
        </DropdownMenuContent>
      </DropdownMenu>
    </div>
  )
}

Here, we check for the presence of a user. If no user is present, we render a SignInButton component. If a user is present, we render a user icon. Clicking on the user icon reveals a dropdown with the user's email and a Sign out button.

Note that the getUser() method sends a request to the Supabase Auth server every time to revalidate the Auth token.

SignInButton component

Create a file named signin-button.tsx in the components folder:

@/components/signin-button.tsx
"use client"
 
import Link from "next/link"
import { usePathname } from "next/navigation"
 
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
import { Icons } from "@/components/icons"
 
export function SignInButton() {
  const pathname = usePathname()
 
  return pathname !== "/signin" ? (
    <Link
      href="/signin"
      className={cn(buttonVariants({ variant: "secondary", size: "sm" }))}
    >
      <Icons.logIn className="mr-2 size-3.5" />
      Sign in
    </Link>
  ) : null
}

SignOutButton component

Create a file named signout-button.tsx in the components folder:

@/components/signout-button.tsx
"use client"
 
import { useRouter } from "next/navigation"
 
import { createClient } from "@/lib/supabase/client"
import { Button } from "@/components/ui/button"
 
export function SignOutButton() {
  const supabase = createClient()
  const router = useRouter()
 
  async function handleLogout() {
    await supabase.auth.signOut()
    router.push("/signin")
    router.refresh()
  }
 
  return (
    <Button className="w-full" onClick={handleLogout}>
      Sign out
    </Button>
  )
}

In the SignOutButton component, we use the signOut() method to sign out the user and then redirect them to the signin page. We also refresh the page to ensure that the UserAccountNav component (which is a server component) accurately renders the authentication state.

Icons component

Create a file named icons.tsx in the components folder:

@/components/icons.tsx
import { LoaderCircle, LogIn, LogOut, LucideProps } from "lucide-react"
 
export const Icons = {
  logIn: LogIn,
  logOut: LogOut,
  loaderCircle: LoaderCircle,
  google: ({ ...props }: LucideProps) => (
    <svg
      aria-hidden="true"
      focusable="false"
      role="img"
      xmlns="http://www.w3.org/2000/svg"
      viewBox="0 0 48 48"
      {...props}
    >
      <path
        fill="#fbc02d"
        d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12	s5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24s8.955,20,20,20	s20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"
      />
      <path
        fill="#e53935"
        d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039	l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"
      />
      <path
        fill="#4caf50"
        d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36	c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"
      />
      <path
        fill="#1565c0"
        d="M43.611,20.083L43.595,20L42,20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571	c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"
      />
    </svg>
  ),
}

Now that we've created our main navigation component, we need it to appear on every page of our application. To achieve this, we'll import and render it inside the root layout file. We'll also render the Toaster component here so that toast notifications can be triggered from any page or component within our app.

@/app/layout.tsx
import type { Metadata } from "next"
import { Inter } from "next/font/google"
 
import "./globals.css"
 
import { Toaster } from "@/components/ui/toaster"
import { MainNav } from "@/components/main-nav"
 
const inter = Inter({ subsets: ["latin"] })
 
export const metadata: Metadata = {
  title: "Supabase Auth + Next.js Demo",
}
 
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <MainNav />
        <main className="flex-1 p-10">{children}</main>
        <Toaster />
      </body>
    </html>
  )
}

Create pages

Now that we have our core components ready, let's create the pages that will be rendered when we click on the links in the main navigation.

Home page

Replace the code inside the app/page.tsx file with the code below:

@/app/page.tsx
export default function HomePage() {
  return <h1 className="text-lg font-medium">Welcome home!</h1>
}

Client page

Inside the app folder, create a folder named client and inside client, create a file named page.tsx:

@/app/client/page.tsx
"use client"
 
import { useUser } from "@/hooks/use-user"
 
export default function ClientPage() {
  const { loading, error, user, role } = useUser()
 
  return (
    <div className="space-y-4">
      {loading ? (
        <p>Loading...</p>
      ) : error ? (
        <p>Error: {error.message}</p>
      ) : (
        <>
          <h1 className="text-lg font-medium">User: {user?.email || "N/A"}</h1>
          <h2 className="text-lg font-medium"> Role: {role || "N/A"}</h2>
        </>
      )}
      <p className="text-muted-foreground">(I am a client component.)</p>
    </div>
  )
}

Server page

Inside the app folder, create a folder named server and inside server, create a file named page.tsx:

@/app/server/page.tsx
import { getUserRole } from "@/lib/get-user-role"
import { createClient } from "@/lib/supabase/server"
 
export default async function ServerPage() {
  const supabase = createClient()
  const {
    data: { user },
  } = await supabase.auth.getUser()
 
  const role = await getUserRole()
 
  return (
    <div className="space-y-4">
      <h1 className="text-lg font-medium">User: {user?.email || "N/A"}</h1>
      <h2 className="text-lg font-medium"> Role: {role || "N/A"}</h2>
      <p className="text-muted-foreground">(I am a server component.)</p>
    </div>
  )
}

Protected page

Inside the app folder, create a folder named protected and inside protected, create a file named page.tsx:

@/app/protected/page.tsx
export default function ProtectedPage() {
  return <h1 className="text-lg font-medium">I am a protected component.</h1>
}

Admin page

Inside the app folder, create a folder named admin and inside admin, create a file named page.tsx:

@/app/admin/page.tsx
import { createClient } from "@/lib/supabase/server"
 
export default async function AdminPage() {
  const supabase = createClient()
 
  const { data: users, error } = await supabase
    .from("users")
    .select("id, email, role")
 
  return (
    <div className="space-y-4">
      <h1 className="text-lg font-medium">Welcome admin!</h1>
      <h2>User List:</h2>
      {error ? (
        <p>Error loading users: {error.message}</p>
      ) : (
        <ul className="text-muted-foreground text-sm">
          {users.map(({ id, email, role }) => (
            <li key={id}>
              Email: {email}, Role: {role}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

SignIn page

Inside the app folder, create a folder named signin and inside signin, create a file named page.tsx:

@/app/signin/page.tsx
"use client"
 
import { useState } from "react"
import { useSearchParams } from "next/navigation"
 
import { createClient } from "@/lib/supabase/client"
import { Button } from "@/components/ui/button"
import { toast } from "@/components/ui/use-toast"
import { Icons } from "@/components/icons"
 
export default function SignInPage() {
  const [isGoogleLoading, setIsGoogleLoading] = useState<boolean>(false)
  const supabase = createClient()
 
  const searchParams = useSearchParams()
 
  const next = searchParams.get("next")
 
  async function signInWithGoogle() {
    setIsGoogleLoading(true)
    try {
      const { error } = await supabase.auth.signInWithOAuth({
        provider: "google",
        options: {
          redirectTo: `${window.location.origin}/auth/callback${
            next ? `?next=${encodeURIComponent(next)}` : ""
          }`,
        },
      })
 
      if (error) {
        throw error
      }
    } catch (error) {
      toast({
        title: "Please try again.",
        description: "There was an error logging in with Google.",
        variant: "destructive",
      })
      setIsGoogleLoading(false)
    }
  }
 
  return (
    <Button
      type="button"
      variant="outline"
      onClick={signInWithGoogle}
      disabled={isGoogleLoading}
    >
      {isGoogleLoading ? (
        <Icons.loaderCircle className="mr-2 size-4 animate-spin" />
      ) : (
        <Icons.google className="mr-2 size-6" />
      )}{" "}
      Sign in with Google
    </Button>
  )
}

When a user clicks the Sign in with Google button, the following sequence of events occurs:

  • The signInWithGoogle function is called, initiating the OAuth flow with Google.
  • The user is redirected to Google's authentication page, where they sign in and grant necessary permissions.
  • After successful authentication, Google redirects the user back to our application's /auth/callback route. Notice how we include the next parameter in this redirect URL, ensuring we don't lose context of where the user originally intended to go.

Set up Auth callback route handler

Inside the app folder, create a folder named auth and inside auth, create a folder naned callback and inside callback, create a file named route.ts:

@/app/auth/callback/route.ts
import { NextResponse } from "next/server"
 
import { createClient } from "@/lib/supabase/server"
 
export async function GET(request: Request) {
  // Extract search parameters and origin from the request URL
  const { searchParams, origin } = new URL(request.url)
 
  // Get the authorization code and the 'next' redirect path
  const code = searchParams.get("code")
  const next = searchParams.get("next") ?? "/"
 
  if (code) {
    // Create a Supabase client
    const supabase = createClient()
 
    // Exchange the code for a session
    const { error } = await supabase.auth.exchangeCodeForSession(code)
 
    if (!error) {
      // If successful, redirect to the 'next' path or home
      return NextResponse.redirect(`${origin}${next}`)
    }
  }
 
  // If there's no code or an error occurred, redirect to an error page
  return NextResponse.redirect(`${origin}/auth/auth-code-error`)
}

After Google authenticates the user and redirects them back to our application's /auth/callback route, the following process occurs:

  • The GET function handles the incoming request to the /auth/callback route.
  • We extract the code (authorization code) sent by Google.
  • We also retrieve the next parameter, which contains the path where the user should be redirected after successful authentication. If it's not present, we default to the root path ("/").
  • If an authorization code is present, we create a Supabase client.
  • We then use Supabase's exchangeCodeForSession method to exchange the authorization code for a user session. This step completes the OAuth flow, establishing the user's authenticated session in our application.
  • If the code exchange is successful (no error), we redirect the user to the path specified in the next parameter, or the home page if no specific path was provided.
  • If there's no authorization code, or if an error occurs during the code exchange, we redirect the user to an error page.

This callback handler ensures that after Google's authentication, we complete the process by establishing a session in our application and then direct the user to their intended destination.

Conclusion

Our implementation of Google sign-in using Next.js and Supabase Auth is complete. Now it's time to put your work to the test:

  • Click through the links in the navigation bar to explore all the functionality you've implemented.
  • Try signing in and out to ensure the authentication flow works smoothly.
  • Check if the user information is correctly displayed and updated across different pages.

If you have ideas on how to improve this implementation, spot any mistakes, or have any questions, please don't hesitate to leave a comment below.

You can find the complete source code for this project on GitHub.

Last updated on Monday, July 22nd, 2024