Menu

Next.js Project Supabase Auth Integration Guide

Based on the official Supabase Next.js authentication guide and the actual code from the Nexty.dev template, this documentation will detail the code integration steps and usage methods for Supabase Auth in Next.js projects.

Detailed Integration Steps

Step 1: Install Dependencies

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

Package descriptions:

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

Step 2: Environment Variable Configuration

Create a .env.local file and configure the necessary 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

For detailed environment variable descriptions, please see: Environment Variables

Step 3: Create Supabase Clients

3.1 Browser Client

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

This is a client dedicated to the browser environment, used for:

  • Handling user interactions (login, registration, data updates)
  • Listening to real-time subscription data changes
  • Providing 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
          }
        },
      },
    }
  )
}

This is a client dedicated to server-side components, used for:

  • Database operations in Server Actions
  • API handling in Route Handlers
  • Managing user sessions through Cookies

Step 4: Set Up Middleware Authentication

We need to first complete the access control logic required by the middleware based on Supabase functionality.

lib/supabase/middleware.ts
/*
* This shows the core logic, not the complete file code
*/
 
// Routes requiring admin privileges
const ROLE_PROTECTED_ROUTES: Record<string, string[]> = {
  'dashboard/users': ['admin'],
};
 
// Routes requiring login
const PROTECTED_ROUTES = ['dashboard'];
 
export async function updateSession(request: NextRequest) {
  // Get user information
  const { data: { user } } = await supabase.auth.getUser()
  
  // Check login status
  if (!user && isProtectedRoute) {
    return NextResponse.redirect(new URL("/login", request.url))
  }
  
  // Check role permissions
  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))
    }
  }
}

This file provides authentication state management logic:

  • Check and update user authentication status through updateSession()
  • Handle token refresh, session validation, etc.

It also provides access control logic:

  • Admin-only pages are forbidden to other users
  • Logged-in user pages are forbidden to unauthenticated users
  • Redirect to login page with callback address, allowing users to directly enter target page after successful login

Import lib/supabase/middleware.ts into middleware.ts to make the above logic effective.

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 completes the full middleware requirements:

  • Automatically refresh expired authentication tokens
  • Permission checking, protecting routes that require login
  • Role verification, checking admin privileges
  • Internationalization integration

Step 5: Frontend Authentication State Management

In the [locale]/layout.tsx file, we introduce <AuthProvider> to implement global frontend authentication state management.

5.1 Core Features of AuthProvider

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

5.2 Extended User Information

The template extends Supabase's user type, adding role information:

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";
};

If your frontend needs important user data from the User table or other data tables, you can extend it in the fetchUserRole method.

5.3 Real-time Role Updates

The template implements real-time listening for role changes:

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

5.4 Multiple Login Methods Support

Social Login:

const signInWithGoogle = async () => {
  return await supabase.auth.signInWithOAuth({
    provider: "google",
    options: {
      redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
    },
  });
};
 
const signInWithGithub = async () => {
  return await supabase.auth.signInWithOAuth({
    provider: "github",
    options: {
      redirectTo: `${window.location.origin}/auth/callback?next=${next}`,
    },
  });
};

Email Magic Link:

const signInWithEmail = async (email: string, captchaToken?: string) => {
  return await supabase.auth.signInWithOtp({
    email: normalizeEmail(email),
    options: {
      emailRedirectTo: `${window.location.origin}/auth/callback?next=${next}`,
      captchaToken,
    },
  });
};

Step 6: Authentication Callback Handling

The template implements a complete authentication callback handling mechanism.

6.1 OAuth Callback

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 Confirmation

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))
}

Application in Real Projects

Using in Client Components

import { useAuth } from '@/components/providers/AuthProvider';
 
export default function UserProfile() {
  const { user, loading, signOut } = 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>
  );
}

Using in Server Components

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();
 
  if (error || !user) {
    redirect('/login');
  }
 
  return <p>Hello {user.email}</p>;
}

Using in Server Actions

'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!
  );
  // Execute admin operations
}

Security Best Practices

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

// ✅ Correct - Server-side always validates tokens
const { data: { user } } = await supabase.auth.getUser();
 
// ❌ Wrong - Should not be used on server-side, tokens 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 After Authentication for Sensitive Operations

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
);