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
Step 1: Install Dependencies
npm install @supabase/supabase-js @supabase/ssr
-
@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
Step 3: Configure OAuth Providers
Supabase Dashboard
Go to Authentication > Providers in your Supabase Dashboard.
GitHub Setup
- Go to GitHub Developer Settings
- Create a new OAuth App
- Set Authorization callback URL:
https://your-project.supabase.co/auth/v1/callback
- Copy Client ID and Client Secret to Supabase Dashboard
Google Setup
- Go to Google Cloud Console
- Create OAuth 2.0 credentials
- Add authorized redirect URI:
https://your-project.supabase.co/auth/v1/callback
- Copy Client ID and Client Secret to Supabase Dashboard
X (Twitter) Setup
- Go to X Developer Portal
- Create a new app with OAuth 2.0 enabled
- Set Callback URI:
https://your-project.supabase.co/auth/v1/callback
- 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>;
}
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
}
},
},
}
);
}
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>
);
}
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`);
}
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;
}
// 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)$).*)",
],
};
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 = "/";
};
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"
}]
}
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
In Supabase Dashboard:
- Disable "X / Twitter (OAuth 2.0)"
- Enable "Twitter (Deprecated)"
- 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>
)}
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!
Lessons Learned
- Always handle unconfigured state - Your app should work (gracefully degraded) even without Supabase credentials
- Use singleton pattern for browser client - Avoid creating multiple instances
- Session refresh is crucial - The middleware keeps sessions fresh automatically
- Test OAuth on production URLs - Some providers (like X) have issues with localhost
- 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!
Top comments (0)