Switch to Supabase Auth

How to change the authentication provider to Supabase Auth.

Supabase Auth is a comprehensive authentication system that integrates seamlessly with Supabase's Postgres database. It provides email/password, magic link, OAuth, and phone authentication, along with user management and session handling.

next-forge uses Clerk as the authentication provider. This guide will help you switch from Clerk to Supabase Auth. Since Supabase doesn't provide built-in organization management like Clerk, this guide includes a multi-tenancy schema pattern to implement team/organization features with role-based access control.

This guide assumes you've already migrated to Supabase for your database. If you haven't done so yet, complete that migration first.

1. Replace the auth package dependencies

Uninstall the existing Clerk dependencies from the auth package...

Terminal
pnpm remove @clerk/nextjs @clerk/themes @clerk/types --filter @repo/auth

...and install the Supabase Auth dependencies:

Terminal
pnpm add @supabase/supabase-js @supabase/ssr --filter @repo/auth

Additionally, add @repo/database to the auth package dependencies to enable organization/team management.

2. Update environment variables

Add the following environment variables to your .env.local file in each Next.js application (app, web, and api). You can find these values in your Supabase project's Settings → API page:

.env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key

The anon key is safe to use in client-side code as it respects your Row Level Security (RLS) policies.

3. Set up the database schema for organizations

Since Supabase doesn't provide built-in organization management, you'll need to create a multi-tenancy schema. Add the following to your Prisma schema:

packages/database/prisma/schema.prisma
model Organization {
  id        String   @id @default(cuid())
  name      String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  members OrganizationMember[]
  @@map("organizations")
}

model OrganizationMember {
  id             String   @id @default(cuid())
  userId         String
  organizationId String
  role           String   @default("member") // owner, admin, member
  createdAt      DateTime @default(now())
  updatedAt      DateTime @updatedAt

  organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)

  @@unique([userId, organizationId])
  @@index([userId])
  @@index([organizationId])
  @@map("organization_members")
}

Then run the migration:

Terminal
pnpm run migrate

4. Create Supabase client utilities

Create utility functions to initialize Supabase clients for different contexts:

Server Client

packages/auth/server.ts
import 'server-only';
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export const createClient = async () => {
  const cookieStore = await 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.
          }
        },
      },
    }
  );
};

// Helper function to get the current user
export const currentUser = async () => {
  const supabase = await createClient();
  const { data: { user } } = await supabase.auth.getUser();
  return user;
};

// Helper function to get the current user's active organization
export const auth = async () => {
  const user = await currentUser();

  if (!user) {
    return { userId: null, orgId: null };
  }

  // Get active organization from user metadata
  const orgId = user.user_metadata?.activeOrganizationId as string | null;

  return {
    userId: user.id,
    orgId,
  };
};

Client Component Client

packages/auth/client.ts
'use client';
import { createBrowserClient } from '@supabase/ssr';

export const createClient = () => {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );
};

5. Update the middleware

Update the middleware.ts file to handle Supabase session refresh:

packages/auth/middleware.ts
import 'server-only';
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export const authMiddleware = async (request: NextRequest) => {
  let supabaseResponse = NextResponse.next({
    request,
  });

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

  // Refreshing the auth token
  const { data: { user } } = await supabase.auth.getUser();

  // Redirect to sign-in if accessing protected route without authentication
  if (!user && request.nextUrl.pathname.startsWith('/dashboard')) {
    const url = request.nextUrl.clone();
    url.pathname = '/sign-in';
    return NextResponse.redirect(url);
  }

  return supabaseResponse;
};

6. Update the auth components

Update both the sign-in.tsx and sign-up.tsx components to use Supabase Auth:

Sign In

packages/auth/components/sign-in.tsx
'use client';

import { createClient } from '../client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export const SignIn = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();
  const supabase = createClient();

  const handleSignIn = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const { error } = await supabase.auth.signInWithPassword({
      email,
      password,
    });

    if (error) {
      setError(error.message);
      setLoading(false);
    } else {
      router.push('/dashboard');
      router.refresh();
    }
  };

  return (
    <form onSubmit={handleSignIn}>
      {error && <div className="text-red-500">{error}</div>}
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Signing in...' : 'Sign in'}
      </button>
    </form>
  );
};

Sign Up

packages/auth/components/sign-up.tsx
'use client';

import { createClient } from '../client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export const SignUp = () => {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [name, setName] = useState('');
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const router = useRouter();
  const supabase = createClient();

  const handleSignUp = async (e: React.FormEvent) => {
    e.preventDefault();
    setLoading(true);
    setError(null);

    const { error } = await supabase.auth.signUp({
      email,
      password,
      options: {
        data: {
          name,
        },
      },
    });

    if (error) {
      setError(error.message);
      setLoading(false);
    } else {
      router.push('/verify-email');
      router.refresh();
    }
  };

  return (
    <form onSubmit={handleSignUp}>
      {error && <div className="text-red-500">{error}</div>}
      <input
        type="text"
        value={name}
        onChange={(e) => setName(e.target.value)}
        placeholder="Name"
        required
      />
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        placeholder="Email"
        required
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        placeholder="Password"
        required
        minLength={6}
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Signing up...' : 'Sign up'}
      </button>
    </form>
  );
};

7. Update the Provider file

Supabase Auth doesn't require a Provider component for basic functionality, so replace it with a stub:

packages/auth/provider.tsx
import type { ReactNode } from 'react';

type AuthProviderProps = {
  children: ReactNode;
};

export const AuthProvider = ({ children }: AuthProviderProps) => children;

8. Implement organization management

Create helper functions to manage organizations in your application. Add these to a new file:

packages/auth/organizations.ts
import 'server-only';
import { database } from '@repo/database';
import { createClient } from './server';

export const createOrganization = async (name: string, userId: string) => {
  const organization = await database.organization.create({
    data: {
      name,
      members: {
        create: {
          userId,
          role: 'owner',
        },
      },
    },
  });

  // Set as active organization
  const supabase = await createClient();
  await supabase.auth.updateUser({
    data: { activeOrganizationId: organization.id },
  });

  return organization;
};

export const getOrganizations = async (userId: string) => {
  return await database.organization.findMany({
    where: {
      members: {
        some: {
          userId,
        },
      },
    },
    include: {
      members: true,
    },
  });
};

export const switchOrganization = async (organizationId: string) => {
  const supabase = await createClient();
  await supabase.auth.updateUser({
    data: { activeOrganizationId: organizationId },
  });
};

export const inviteToOrganization = async (
  organizationId: string,
  email: string,
  role: string = 'member'
) => {
  // Implement your invitation logic here
  // This could involve creating an invitation record and sending an email
};

9. Set up auth callback route

Create a callback route to handle authentication redirects:

apps/app/app/api/auth/callback/route.ts
import { createClient } from '@repo/auth/server';
import { NextResponse } from 'next/server';

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

  if (code) {
    const supabase = await createClient();
    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`);
}

10. Update your apps

Replace any remaining Clerk implementations in your apps with Supabase Auth equivalents:

Server Components

// Before (Clerk)
const { userId, orgId } = await auth();
const user = await currentUser();

// After (Supabase)
import { auth, currentUser } from '@repo/auth/server';

const { userId, orgId } = await auth();
const user = await currentUser();

Client Components

// Before (Clerk)
import { useUser } from '@clerk/nextjs';
const { user } = useUser();

// After (Supabase)
'use client';
import { createClient } from '@repo/auth/client';
import { useEffect, useState } from 'react';

const supabase = createClient();
const [user, setUser] = useState(null);

useEffect(() => {
  supabase.auth.getUser().then(({ data: { user } }) => setUser(user));

  const { data: { subscription } } = supabase.auth.onAuthStateChange(
    (_event, session) => setUser(session?.user ?? null)
  );

  return () => subscription.unsubscribe();
}, []);

Sign Out

// Before (Clerk)
import { SignOutButton } from '@clerk/nextjs';
<SignOutButton />

// After (Supabase)
'use client';
import { createClient } from '@repo/auth/client';

const handleSignOut = async () => {
  const supabase = createClient();
  await supabase.auth.signOut();
  router.push('/');
  router.refresh();
};

<button onClick={handleSignOut}>Sign out</button>

Additional features

Social Authentication

To add OAuth providers, configure them in your Supabase project settings, then use:

const { error } = await supabase.auth.signInWithOAuth({
  provider: 'github', // or 'google', 'apple', etc.
  options: {
    redirectTo: `${window.location.origin}/api/auth/callback`,
  },
});
const { error } = await supabase.auth.signInWithOtp({
  email,
  options: {
    emailRedirectTo: `${window.location.origin}/api/auth/callback`,
  },
});

To secure your database tables with Row Level Security (RLS) policies, see the Supabase database migration guide for detailed setup instructions.

For more information, see the Supabase Auth documentation.