Menu

Supabase Auth Integration Guide for Next.js

This guide builds upon the official Supabase Next.js authentication guide and includes practical examples from the Nexty.dev boilerplate to demonstrate step-by-step integration and usage patterns for Supabase Auth in Next.js projects.

Integration Steps

Step 1: Install Dependencies

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

Dependency Explanation:

  • @supabase/supabase-js - Supabase JavaScript client library
  • @supabase/ssr - Helper library optimized for server-side rendering

Step 2: Environment Variables Configuration

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

# Supabase basic configuration
NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
SUPABASE_SERVICE_ROLE_KEY=your_supabase_service_role_key
NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id

For detailed environment variable information, please read: Environment Variables

Step 3: Create Supabase Clients

3.1 Client-side Client

lib/supabase/client.ts
import { Database } from '@/lib/supabase/types'
import { createBrowserClient } from '@supabase/ssr'
 
export const createClient = () => {
  // Create browser client for client-side operations
  return createBrowserClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

The browser client is designed for client-side operations and provides:

  • User interaction handling (login, registration, data updates)
  • Real-time data subscriptions and updates
  • Authentication state management

3.2 Server-side Client

lib/supabase/server.ts
import { Database } from '@/lib/supabase/types'
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
 
export async function createClient() {
  const cookieStore = await cookies()
 
  return createServerClient<Database>(
    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 {
            // setAll calls in Server Components will be ignored
          }
        },
      },
    }
  )
}

The server client is optimized for server-side operations and handles:

  • Database operations within Server Actions
  • API request processing in Route Handlers

Step 4: Set Up Middleware Authentication

First, we'll implement the authentication middleware logic using Supabase's authentication features.

lib/supabase/middleware.ts
/*
 * Core middleware authentication logic
 * Note: This is a simplified example. See the complete implementation in the source code.
 */
 
// Admin-only routes configuration
const ROLE_PROTECTED_ROUTES: Record<string, string[]> = {
  'dashboard/users': ['admin'],
};
 
// Routes that require authentication
const PROTECTED_ROUTES = ['dashboard'];
 
export async function updateSession(request: NextRequest) {
  // Retrieve current user from Supabase
  const { data: { user } } = await supabase.auth.getUser()
  
  // Redirect unauthenticated users from protected routes
  if (!user && isProtectedRoute) {
    return NextResponse.redirect(new URL("/login", request.url))
  }
  
  // Verify user role for role-protected routes
  if (user && needsRoleCheck) {
    const { data } = await supabase.from("users").select("role").eq("id", user.id).single();
    if (!allowedRoles.includes(data?.role || "user")) {
      return NextResponse.redirect(new URL("/403", request.url))
    }
  }
}

The lib/supabase/middleware.ts file is imported by Next.js's middleware.ts and runs on every request. It serves as the authentication gateway, handling:

  • Cookie validation and refresh
  • Session management
  • User authentication status verification

Key responsibilities:

  • Session Management: Validate and refresh user sessions via updateSession()
  • Cookie Handling: Manage authentication cookies and token refresh
  • Route Protection: Implement access control for protected routes

Since this middleware runs on every request, it enables comprehensive access control:

Authentication Layers:

  • Login Protection: Redirects unauthenticated users from protected routes
  • Role-based Access: Restricts admin-only pages to authorized users

The complete middleware implementation integrates authentication with internationalization:

middleware.ts
import { updateSession } from '@/lib/supabase/middleware';
import createIntlMiddleware from 'next-intl/middleware';
import { NextRequest, NextResponse } from 'next/server';
import { routing } from './i18n/routing';
 
const intlMiddleware = createIntlMiddleware(routing);
 
export async function middleware(request: NextRequest): Promise<NextResponse> {
  // 1. First handle Supabase authentication
  const supabaseResponse = await updateSession(request);
 
  if (supabaseResponse.headers.get('location')) {
    return supabaseResponse;
  }
 
  // 2. Then handle internationalization routing
  const intlResponse = intlMiddleware(request);
 
  // 3. Merge cookies
  supabaseResponse.cookies.getAll().forEach((cookie) => {
    const { name, value, ...options } = cookie;
    intlResponse.cookies.set(name, value, options);
  });
 
  return intlResponse;
}

This implementation provides comprehensive middleware functionality:

  • Automatic token refresh for expired sessions
  • Route protection for authenticated users
  • Role-based access control for admin features
  • Seamless internationalization integration

Step 5: Frontend Authentication State Management

The <AuthProvider> component, integrated into [locale]/layout.tsx, provides global authentication state management across the application.

5.1 Core Functions of AuthProvider

AuthProvider.tsx
type AuthContextType = {
  user: ExtendedUser | null;           // User object with role information
  loading: boolean;                    // Loading state
  signInWithGoogle: (next?: string) => Promise<{ error: AuthError | null }>;
  signInWithGithub: (next?: string) => Promise<{ error: AuthError | null }>;
  signInWithEmail: (
    email: string,
    captchaToken?: string,
    next?: string
  ) => Promise<{ error: AuthError | null }>;
  signOut: () => Promise<void>;
  refreshUser: () => Promise<void>;
};

5.2 Extended User Information

The boilerplate enhances Supabase's default user object by including role information from the database:

AuthProvider.tsx
type ExtendedUser = User & {
  role: "admin" | "user";
};
 
const fetchUserRole = async (userId: string) => {
  const { data, error } = await supabase
    .from("users")
    .select("role")
    .eq("id", userId)
    .limit(1)
    .maybeSingle();
 
  return data?.role || "user";
};

Good to know

You can extend the fetchUserRole method to include additional user data from the users table or related tables as needed by your application.

5.3 Real-time Role Synchronization

The authentication provider includes real-time subscriptions to automatically sync role changes:

components/providers/AuthProvider.tsx
const userSubscription = supabase
  .channel("public:users")
  .on(
    "postgres_changes",
    {
      event: "UPDATE",
      schema: "public",
      table: "users",
      filter: `id=eq.${user?.id}`,
    },
    async (payload) => {
      setUser((prevUser) => {
        if (prevUser) {
          return {
            ...prevUser,
            role: payload.new.role,
          };
        }
        return prevUser;
      });
    }
  )
  .subscribe();

5.4 Authentication Methods

The provider supports multiple authentication strategies:

OAuth Providers:

const signInWithGoogle = async (next?: string) => {
  const redirectUrl = new URL(`${window.location.origin}/auth/callback`);
 
  const referral = getReferral();
  redirectUrl.searchParams.set("referral", referral || "direct");
 
  if (next) {
    redirectUrl.searchParams.set("next", next);
  }
  return await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: redirectUrl.toString(),
    },
  });
};
 
const signInWithGithub = async (next?: string) => {
  const redirectUrl = new URL(`${window.location.origin}/auth/callback`);
 
  const referral = getReferral();
  redirectUrl.searchParams.set("referral", referral || "direct");
 
  if (next) {
    redirectUrl.searchParams.set("next", next);
  }
  return await supabase.auth.signInWithOAuth({
    provider: "github",
    options: {
      redirectTo: redirectUrl.toString(),
    },
  });
};

Email Magic Link:

const signInWithEmail = async (
  email: string,
  captchaToken?: string,
  next?: string
) => {
  const redirectUrl = new URL(`${window.location.origin}/auth/callback`);
 
  const referral = getReferral();
  redirectUrl.searchParams.set("referral", referral || "direct");
 
  if (next) {
    redirectUrl.searchParams.set("next", next);
  }
 
  return await supabase.auth.signInWithOtp({
    email: normalizeEmail(email),
    options: {
      emailRedirectTo: redirectUrl.toString(),
      captchaToken,
      data: { referral },
    },
  });
};

Step 6: Authentication Callback Handling

The authentication system includes comprehensive callback handlers to process different authentication flows.

This route handler processes authentication callbacks from OAuth providers (Google, GitHub) and magic link email authentication.

app/auth/callback/route.ts
export async function GET(request: Request) {
  const { searchParams, origin } = new URL(request.url)
  const code = searchParams.get('code')
  let next = searchParams.get('next') || '/'
 
  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)
    if (!error) {
      return NextResponse.redirect(`${origin}${next}`)
    }
  }
 
  return NextResponse.redirect(new URL(`/redirect-error?code=server_error`, origin))
}

6.2 Email Verification Handler

This route processes email verification tokens for various scenarios:

  • Email Registration: Confirming new user accounts
  • Email Changes: Verifying email address updates
  • Password Resets: Validating password reset requests

Good to know

While not used in the Nexty's auth flow, this handler is ready for traditional email/password authentication if you choose to implement it.

app/auth/confirm/route.ts
export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const token_hash = searchParams.get('token_hash')
  const type = searchParams.get('type') as EmailOtpType | null
 
  if (!token_hash || !type) {
    return NextResponse.redirect(new URL(`/redirect-error?code=invalid_params`, origin))
  }
 
  const supabase = await createClient()
  const { error } = await supabase.auth.verifyOtp({
    type,
    token_hash,
  })
 
  if (error) {
    // Detailed error handling
    switch (error.message) {
      case 'Token has expired':
        return NextResponse.redirect(new URL(`/redirect-error?code=token_expired`, origin))
      // ... other error handling
    }
  }
 
  return NextResponse.redirect(new URL(next, origin))
}

Implementation Guide

Client Components

For client-side components, use the useAuth hook to access user data and authentication methods:

import { useAuth } from '@/components/providers/AuthProvider';
 
export default function UserProfile() {
  const { user, loading, signOut } = useAuth(); // Retrive user from useAuth
 
  if (loading) return <div>Loading...</div>;
  
  if (!user) {
    return <LoginButton />;
  }
 
  return (
    <div>
      <h1>Welcome, {user.email}</h1>
      <p>Role: {user.role}</p>
      {user.role === 'admin' && <AdminPanel />}
      <button onClick={signOut}>Sign Out</button>
    </div>
  );
}

Server Components

Server components must validate authentication server-side using supabase.auth.getUser() to prevent cookie tampering:

import { createClient } from '@/lib/supabase/server';
import { redirect } from 'next/navigation';
 
export default async function PrivatePage() {
  const supabase = await createClient();
  const { data: { user }, error } = await supabase.auth.getUser(); // Retrive user from supabase.auth.getUser()
 
  if (error || !user) {
    redirect('/login');
  }
 
  return <p>Hello {user.email}</p>;
}

Server Actions

In Server Actions, authentication for regular users can use supabase.auth.getUser(), but for sensitive operations, you need to verify admin privileges first and then use the JS client package to operate the database, which is safer.

The lib/supabase/isAdmin encapsulates the admin role verification method. In all places that require admin operation privileges, this method must be called first before performing database operations.

'use server'
import { createClient as createAdminClient } from "@supabase/supabase-js";
import { isAdmin } from '@/lib/supabase/isAdmin';
 
export async function adminAction() {
  if (!(await isAdmin())) {
    return actionResponse.forbidden("Admin privileges required.");
  }
 
  const supabaseAdmin = createAdminClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.SUPABASE_SERVICE_ROLE_KEY!
  );
  // Perform admin operations
}

Security Best Practices

1. Use getUser() Instead of getSession() on Server-side

// ✅ Correct - Server-side always validates token
const { data: { user } } = await supabase.auth.getUser();
 
// ❌ Wrong - Should not be used on server-side, token may not be validated
const { data: { session } } = await supabase.auth.getSession();

2. Separate Client and Server-side Logic

// Client component - Use browser client
const supabase = createClient(); // from @/lib/supabase/client
 
// Server component/Actions - Use server-side client  
const supabase = await createClient(); // from @/lib/supabase/server

3. Use Service Role Key for Sensitive Operations After Authentication

For sensitive operations, first verify admin identity, then use Service Role Key to operate the database

import { createClient as createAdminClient } from "@supabase/supabase-js";
import { isAdmin } from '@/lib/supabase/isAdmin';
 
// Verify admin identity
if (!(await isAdmin())) {
  return actionResponse.forbidden("Admin privileges required.");
}
 
// Admin uses Service Role Key to operate database
const supabaseAdmin = createAdminClient<Database>(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY! // Server-side only
);