DEV Community

Cover image for Adding GitHub, Google, and X Login to Next.js 15 with Supabase Auth
mukitaro
mukitaro

Posted on

Adding GitHub, Google, and X Login to Next.js 15 with Supabase Auth

Social login is essential for modern web apps. In this article, I'll show you how I implemented GitHub, Google, and X (Twitter) OAuth in my typing game DevType using Next.js 15 App Router and Supabase Auth.

I'll cover:

  • Setting up OAuth providers in Supabase Dashboard
  • Client-side login implementation
  • Server-side callback handling
  • Middleware for session management
  • A critical gotcha with X OAuth 2.0 (and the workaround)

Prerequisites

  • Next.js 15 with App Router
  • Supabase project
  • Developer accounts for GitHub, Google, and X

Project Structure

src/
├── app/
│   ├── auth/
│   │   └── callback/
│   │       └── route.ts      # OAuth callback handler
│   └── [locale]/
│       └── login/
│           └── page.tsx      # Login page
├── components/
│   └── auth/
│       └── LoginForm.tsx     # Login UI component
├── lib/
│   └── supabase/
│       ├── client.ts         # Browser client
│       ├── server.ts         # Server client
│       └── middleware.ts     # Session management
└── middleware.ts             # Next.js middleware
Enter fullscreen mode Exit fullscreen mode

Step 1: Install Dependencies

npm install @supabase/supabase-js @supabase/ssr
Enter fullscreen mode Exit fullscreen mode
  • @supabase/supabase-js: Core Supabase client
  • @supabase/ssr: Server-side rendering support for Next.js

Step 2: Environment Variables

# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Enter fullscreen mode Exit fullscreen mode

Step 3: Configure OAuth Providers

Supabase Dashboard

Go to Authentication > Providers in your Supabase Dashboard.

GitHub Setup

  1. Go to GitHub Developer Settings
  2. Create a new OAuth App
  3. Set Authorization callback URL:
   https://your-project.supabase.co/auth/v1/callback
Enter fullscreen mode Exit fullscreen mode
  1. Copy Client ID and Client Secret to Supabase Dashboard

Google Setup

  1. Go to Google Cloud Console
  2. Create OAuth 2.0 credentials
  3. Add authorized redirect URI:
   https://your-project.supabase.co/auth/v1/callback
Enter fullscreen mode Exit fullscreen mode
  1. Copy Client ID and Client Secret to Supabase Dashboard

X (Twitter) Setup

  1. Go to X Developer Portal
  2. Create a new app with OAuth 2.0 enabled
  3. Set Callback URI:
   https://your-project.supabase.co/auth/v1/callback
Enter fullscreen mode Exit fullscreen mode
  1. Copy Client ID and Client Secret to Supabase Dashboard

Important: More on X OAuth issues later in this article.

Step 4: Supabase Client Setup

Browser Client (src/lib/supabase/client.ts)

"use client";

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

let client: ReturnType<typeof createBrowserClient> | null = null;

export function isSupabaseConfigured(): boolean {
  return !!(
    process.env.NEXT_PUBLIC_SUPABASE_URL &&
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
  );
}

export function createClient() {
  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

  // Return mock client if not configured (for development)
  if (!url || !key) {
    return createMockClient();
  }

  // Singleton pattern for browser client
  if (client) return client;

  client = createBrowserClient(url, key);
  return client;
}

function createMockClient() {
  // Returns a mock that won't crash your app during development
  return {
    auth: {
      getUser: async () => ({ data: { user: null }, error: null }),
      getSession: async () => ({ data: { session: null }, error: null }),
      signInWithOAuth: async () => ({ data: null, error: new Error("Not configured") }),
      signOut: async () => ({ error: null }),
      onAuthStateChange: () => ({ data: { subscription: { unsubscribe: () => {} } } }),
    },
  } as ReturnType<typeof createBrowserClient>;
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • isSupabaseConfigured() allows checking before attempting auth
  • Mock client prevents crashes when Supabase isn't configured
  • Singleton pattern avoids multiple client instances

Server Client (src/lib/supabase/server.ts)

import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";

export async function createClient() {
  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 {
            // Called from Server Component - ignore
          }
        },
      },
    }
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Login Form Component

// src/components/auth/LoginForm.tsx
"use client";

import { useState } from "react";
import { createClient, isSupabaseConfigured } from "@/lib/supabase/client";

// Custom icons for each provider
function GitHubIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} fill="currentColor">
      <path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
    </svg>
  );
}

function GoogleIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className}>
      <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" />
      <path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" />
      <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" />
      <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" />
    </svg>
  );
}

function XIcon({ className }: { className?: string }) {
  return (
    <svg viewBox="0 0 24 24" className={className} fill="currentColor">
      <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
    </svg>
  );
}

type OAuthProvider = "github" | "google" | "twitter";

export function LoginForm() {
  const [loadingProvider, setLoadingProvider] = useState<OAuthProvider | null>(null);
  const [error, setError] = useState<string | null>(null);

  const supabaseConfigured = isSupabaseConfigured();

  const handleOAuthLogin = async (provider: OAuthProvider) => {
    if (!supabaseConfigured) {
      setError("Authentication is not configured");
      return;
    }

    setLoadingProvider(provider);
    setError(null);

    try {
      const supabase = createClient();
      const redirectTo = `${window.location.origin}/auth/callback`;

      const { error } = await supabase.auth.signInWithOAuth({
        provider,
        options: {
          redirectTo,
        },
      });

      if (error) throw error;
    } catch (err) {
      console.error("Login error:", err);
      setError(err instanceof Error ? err.message : "Login failed");
      setLoadingProvider(null);
    }
  };

  return (
    <div className="space-y-4">
      {error && (
        <div className="text-red-500 text-sm">{error}</div>
      )}

      <button
        onClick={() => handleOAuthLogin("github")}
        disabled={loadingProvider !== null}
        className="w-full flex items-center justify-center gap-2 p-3 border rounded"
      >
        <GitHubIcon className="w-5 h-5" />
        Continue with GitHub
      </button>

      <button
        onClick={() => handleOAuthLogin("google")}
        disabled={loadingProvider !== null}
        className="w-full flex items-center justify-center gap-2 p-3 border rounded"
      >
        <GoogleIcon className="w-5 h-5" />
        Continue with Google
      </button>

      <button
        onClick={() => handleOAuthLogin("twitter")}
        disabled={loadingProvider !== null}
        className="w-full flex items-center justify-center gap-2 p-3 border rounded"
      >
        <XIcon className="w-5 h-5" />
        Continue with X
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 6: OAuth Callback Handler

This is the critical server-side route that exchanges the OAuth code for a session.

// src/app/auth/callback/route.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
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") ?? "/";

  if (code) {
    const cookieStore = await cookies();

    const supabase = createServerClient(
      process.env.NEXT_PUBLIC_SUPABASE_URL!,
      process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
      {
        cookies: {
          getAll() {
            return cookieStore.getAll();
          },
          setAll(cookiesToSet) {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            );
          },
        },
      }
    );

    const { error } = await supabase.auth.exchangeCodeForSession(code);

    if (!error) {
      // Handle reverse proxy (AWS Amplify, etc.)
      const forwardedHost = request.headers.get("x-forwarded-host");
      const forwardedProto = request.headers.get("x-forwarded-proto") ?? "https";

      if (forwardedHost) {
        return NextResponse.redirect(`${forwardedProto}://${forwardedHost}${next}`);
      }

      return NextResponse.redirect(`${origin}${next}`);
    }
  }

  // OAuth error - redirect to error page
  return NextResponse.redirect(`${origin}/auth/auth-code-error`);
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • exchangeCodeForSession() converts the OAuth code to a user session
  • Cookies are set automatically by Supabase
  • Reverse proxy headers support AWS Amplify and similar hosts

Step 7: Session Middleware

Middleware keeps the session fresh by checking and refreshing tokens on every request.

// src/lib/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({ request });

  const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
  const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;

  // Skip if Supabase is not configured
  if (!url || !key) {
    return supabaseResponse;
  }

  const supabase = createServerClient(url, key, {
    cookies: {
      getAll() {
        return request.cookies.getAll();
      },
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value }) =>
          request.cookies.set(name, value)
        );
        supabaseResponse = NextResponse.next({ request });
        cookiesToSet.forEach(({ name, value, options }) =>
          supabaseResponse.cookies.set(name, value, options)
        );
      },
    },
  });

  // This refreshes the session if needed
  await supabase.auth.getUser();

  return supabaseResponse;
}
Enter fullscreen mode Exit fullscreen mode
// src/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: [
    "/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
  ],
};
Enter fullscreen mode Exit fullscreen mode

Step 8: Logout Implementation

// In your header or user menu component
const handleSignOut = async () => {
  const supabase = createClient();

  // Clear local storage if needed
  sessionStorage.removeItem("gameResult");

  // Sign out from all sessions
  await supabase.auth.signOut({ scope: "global" });

  // Redirect to home
  window.location.href = "/";
};
Enter fullscreen mode Exit fullscreen mode

The X (Twitter) OAuth 2.0 Gotcha

Here's something I learned the hard way: Supabase's X OAuth 2.0 implementation has issues.

When I first implemented X login with provider: "x" (OAuth 2.0), users got this error:

{
  "errors": [{
    "error": "invalid_request",
    "error_description": "The action you have taken cannot be performed"
  }]
}
Enter fullscreen mode Exit fullscreen mode

After investigating, I found that Supabase is calling the wrong X API endpoint internally.

The Workaround

Use the deprecated OAuth 1.0a provider instead:

// Instead of:
// provider: "x"  // OAuth 2.0 - broken

// Use:
provider: "twitter"  // OAuth 1.0a - works
Enter fullscreen mode Exit fullscreen mode

In Supabase Dashboard:

  1. Disable "X / Twitter (OAuth 2.0)"
  2. Enable "Twitter (Deprecated)"
  3. Use API Key and API Secret (not Client ID/Secret)

This isn't ideal since OAuth 1.0a is deprecated, but it works reliably until Supabase fixes the OAuth 2.0 implementation.

Bonus: Guest Play Support

Not every user wants to create an account. Here's how to support guest play:

// Check if user is logged in
const [isGuest, setIsGuest] = useState<boolean | null>(null);

useEffect(() => {
  const supabase = createClient();

  supabase.auth.getSession().then(({ data: { session } }) => {
    setIsGuest(!session?.user);
  });

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

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

// Show notice for guests
{isGuest && (
  <div className="text-amber-500">
    Playing as guest. <a href="/login">Log in</a> to save your progress.
  </div>
)}
Enter fullscreen mode Exit fullscreen mode

Auth Flow Diagram

User clicks "Login with GitHub"
         ↓
signInWithOAuth({ provider: "github" })
         ↓
Redirect to GitHub OAuth page
         ↓
User authorizes the app
         ↓
GitHub redirects to Supabase callback
(https://your-project.supabase.co/auth/v1/callback)
         ↓
Supabase redirects to your app
(/auth/callback?code=xxx)
         ↓
exchangeCodeForSession(code)
         ↓
Session stored in cookies
         ↓
User is logged in!
Enter fullscreen mode Exit fullscreen mode

Lessons Learned

  1. Always handle unconfigured state - Your app should work (gracefully degraded) even without Supabase credentials
  2. Use singleton pattern for browser client - Avoid creating multiple instances
  3. Session refresh is crucial - The middleware keeps sessions fresh automatically
  4. Test OAuth on production URLs - Some providers (like X) have issues with localhost
  5. Have a fallback for broken OAuth - X OAuth 2.0 doesn't work, so I use 1.0a

Conclusion

Setting up multi-provider OAuth with Supabase and Next.js 15 is straightforward once you understand the flow. The main challenges are:

  • Proper cookie handling between client and server
  • Session management in middleware
  • Provider-specific quirks (looking at you, X)

If you want to see this implementation in action, try DevType - a typing practice game for programmers.


Have questions? Found a bug in my code? Let me know in the comments!

Resources

Top comments (0)