How to Integrate Supabase Auth with Next.js (JavaScript Only)


Step 1: Create useAuth hook
// src/hooks/useAuth.js
'use client'
import { createContext, useContext, useEffect, useState } from 'react'
import supabase from '@/libs/supabase/client'
const AuthContext = createContext({
session: null,
user: null,
loading: true,
signUp: async () => { },
signIn: async () => { },
signInWithGoogle: async () => { },
signOut: async () => { },
forgotPassword: async () => { }
});
export const AuthProvider = ({ children }) => {
const [session, setSession] = useState(null);
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchSession = async () => {
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('Error fetching session:', error);
} else {
setSession(session);
setUser(session?.user ?? null);
}
setLoading(false);
};
const { data: { subscription } } = supabase.auth.onAuthStateChange(
async (_event, session) => {
setSession(session);
setUser(session?.user ?? null);
setLoading(false);
}
);
fetchSession();
return () => {
subscription?.unsubscribe();
};
}, []);
const signUp = async (email, password, username) => {
// Check if username already exists in auth.users -> user_metadata.username
const { data: usersWithSameUsername, error: fetchError } = await supabase
.from('users_view') // You need to create this view in your Supabase DB
.select('id')
.eq('user_metadata->>username', username)
if (fetchError) throw fetchError
if (usersWithSameUsername.length > 0) {
throw new Error('Username already taken')
}
// Sign up new user
const { error: signupError } = await supabase.auth.signUp({
email,
password,
options: {
data: {
username,
},
},
})
if (signupError) throw signupError
}
const signIn = async (email, password) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw new Error(error.message);
};
const signInWithGoogle = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/callback` }
});
if (error) throw error;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) throw error;
window.location.href = '/';
};
const forgotPassword = async (email) => {
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) throw error;
};
return (
<AuthContext.Provider
value={{
session,
user,
loading,
signUp,
signIn,
signInWithGoogle,
signOut,
forgotPassword
}}
>
{!loading && children}
</AuthContext.Provider>
);
};
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) throw new Error('useAuth must be used within an AuthProvider');
return context;
};
Step 2: Provide AuthProvider to the layout.js and create supabase client
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from '@/hooks/useAuth'
import Navbar from "@/components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
// src/libs/supabase/client.js
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
},
});
export default supabase;
// make sure in main_directory/.env.local
// NEXT_PUBLIC_SUPABASE_URL=https://your_supabase_url.supabase.co
// NEXT_PUBLIC_SUPABASE_ANON_KEY=your_anon_key
// refer: https://supabase.com/dashboard/project/settings/api
Step 3 : Create authentication page for signup/login
//app/auth/page.jsx
'use client';
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useAuth } from '@/hooks/useAuth';
import dynamic from 'next/dynamic';
const LoginForm = dynamic(() => import('@/components/authentication/LoginForm'), { ssr: false });
const SignUpForm = dynamic(() => import('@/components/authentication/SignUpForm'), { ssr: false });
export default function AuthPage() {
const { user, loading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const initialMode = searchParams.get('status') === 'signup' ? 'signup' : 'login';
const [authMode, setAuthMode] = useState(initialMode);
useEffect(() => {
if (!loading && user) {
router.push('/');
}
}, [user, loading, router]);
const handleModeChange = (mode) => {
setAuthMode(mode);
const newUrl = `/auth?status=${mode}`;
router.push(newUrl);
};
return (
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-white">
<div className="bg-white rounded-xl shadow-md p-6 w-full max-w-md">
<h2 className="text-center text-2xl font-bold mb-4">
{authMode === 'login' ? 'Login to Your App Name' : 'Sign Up for Your App Name'}
</h2>
<div className="flex justify-center space-x-4 mb-6">
<button
onClick={() => handleModeChange('login')}
className={`px-4 py-2 rounded-md text-sm font-medium ${authMode === 'login' ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
Login
</button>
<button
onClick={() => handleModeChange('signup')}
className={`px-4 py-2 rounded-md text-sm font-medium ${authMode === 'signup' ? 'bg-orange-500 text-white' : 'bg-gray-200 text-gray-700'
}`}
>
Sign Up
</button>
</div>
{/* Login or Signup Form */}
<div>
{authMode === 'login' ? (
<>
<LoginForm />
</>
) : (
<SignUpForm />
)}
</div>
</div>
</div>
);
}
Step 4: Create Authentication sub components
Create Account Component
// src/components/authentication/SignUpForm.js
'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { Mail, Lock, User, Loader2, Eye, EyeOff } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
export default function SignUpForm() {
const [loading, setLoading] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const { signUp } = useAuth()
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm()
const onSubmit = async (data) => {
try {
setLoading(true)
await signUp(data.email, data.password, data.username)
alert('Account created successfully')
reset()
} catch (error) {
alert(error.message || 'Something went wrong')
} finally {
setLoading(false)
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 font-sans max-w-md mx-auto">
{/* Username */}
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium">Username</label>
<div className="relative">
<User className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
{...register('username', {
required: 'Username is required',
pattern: {
value: /^[A-Za-z0-9_]{3,16}$/,
message: '3–16 characters, letters, numbers, or underscores',
},
})}
id="username"
type="text"
placeholder="Choose a username"
className="w-full pl-10 pr-3 py-2 border rounded-md focus:outline-none focus:ring"
/>
</div>
{errors.username && <p className="text-sm text-red-500">{errors.username.message}</p>}
</div>
{/* Email */}
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
id="email"
type="email"
placeholder="Enter your email"
className="w-full pl-10 pr-3 py-2 border rounded-md focus:outline-none focus:ring"
/>
</div>
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
{/* Password */}
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
{...register('password', {
required: 'Password is required',
minLength: {
value: 8,
message: 'Password must be at least 8 characters',
},
})}
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Create a password"
className="w-full pl-10 pr-10 py-2 border rounded-md focus:outline-none focus:ring"
/>
<button
type="button"
onClick={() => setShowPassword((prev) => !prev)}
className="absolute right-3 top-2.5 text-gray-500"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
{/* Submit Button */}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-md flex items-center justify-center hover:bg-blue-700 transition"
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Account
</button>
</form>
)
}
// use this sql :
// create or replace view users_view as
// select
// id,
// email,
// raw_user_meta_data as user_metadata
// from auth.users;
Login Form Component
// src/components/authentication/LoginForm.js
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { Mail, Lock, Eye, EyeOff, Loader2 } from 'lucide-react';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/navigation';
import supabase from '@/libs/supabase/client';
const ForgotPasswordForm = dynamic(() => import('@/components/authentication/ForgotPasswordPage'), {
ssr: false,
});
export default function LoginForm() {
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [showForgot, setShowForgot] = useState(false);
const router = useRouter();
const {
register,
handleSubmit,
formState: { errors },
} = useForm();
const onSubmit = async (data) => {
try {
setLoading(true);
const { error } = await supabase.auth.signInWithPassword({
email: data.email,
password: data.password,
});
if (error) {
alert('Login failed: ' + error.message);
} else {
alert('Login successful!');
router.push('/myprofile');
}
} catch (err) {
alert('Login Failed');
} finally {
setLoading(false);
}
};
const signInWithGoogle = async () => {
const { error } = await supabase.auth.signInWithOAuth({ provider: 'google' });
if (!error) router.push('/myprofile');
if (error) alert('Google Sign-in failed: ' + error.message);
};
return (
<>
<div className='space-y-6 max-w-md mx-auto p-6 border rounded-md shadow-md bg-white'>
<form
onSubmit={handleSubmit(onSubmit)}
className=""
>
<div className="space-y-1">
<label htmlFor="email" className="block text-sm font-medium">Email</label>
<div className="relative">
<Mail className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email address',
},
})}
type="email"
id="email"
placeholder="Enter your email"
className="w-full pl-10 pr-3 py-2 border rounded focus:outline-none focus:ring-2 text-black"
/>
</div>
{errors.email && <p className="text-sm text-red-500">{errors.email.message}</p>}
</div>
<div className="space-y-1">
<label htmlFor="password" className="block text-sm font-medium">Password</label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-gray-400" />
<input
{...register('password', { required: 'Password is required' })}
type={showPassword ? 'text' : 'password'}
id="password"
placeholder="Enter your password"
className="w-full pl-10 pr-10 py-2 border rounded focus:outline-none focus:ring-2 text-black"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-2.5"
>
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
{errors.password && <p className="text-sm text-red-500">{errors.password.message}</p>}
</div>
<button
type="submit"
disabled={loading}
className="w-full flex justify-center items-center gap-2 px-4 py-2 bg-blue-500 text-black rounded hover:bg-blue-600 transition"
>
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
Login
</button>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-sm">
<span className="bg-white rounded-3xl px-2 text-gray-500">Or continue with</span>
</div>
</div>
<button
type="button"
onClick={signInWithGoogle}
disabled={loading}
className="w-full flex items-center justify-center gap-2 border px-4 py-2 rounded-2xl"
>
<img
src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg"
alt="Google"
className="h-5 w-5"
/>
<span className="text-sm font-medium">Continue with Google</span>
</button>
</form>
<div className="mt-4 text-center">
<div
type="button"
onClick={() => setShowForgot((prev) => !prev)}
className="text-sm text-blue-600 hover:underline"
>
<ForgotPasswordForm />
</div>
</div>
</div>
</>
);
}
Forgot Password Component
// src/components/authentication/ForgotPasswordPage.js
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/hooks/useAuth';
export default function ForgotPasswordPage() {
const [loading, setLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [message, setMessage] = useState(null);
const { forgotPassword } = useAuth();
const {
register,
handleSubmit,
formState: { errors },
reset,
} = useForm();
const onSubmit = async (data) => {
setLoading(true);
setMessage(null);
try {
await forgotPassword(data.email);
alert('✅ Password reset instructions sent!');
setIsOpen(false);
reset();
} catch (err) {
setMessage('❌ ' + (err.message || 'Something went wrong.'));
} finally {
setLoading(false);
}
};
return (
<div className="w-full">
<button
onClick={() => setIsOpen(!isOpen)}
className="text-sm text-black bg-cyan-200 px-4 py-1 rounded-full hover:bg-cyan-300"
>
Forgot password?
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -5 }}
transition={{ duration: 0.2 }}
className="mt-2 p-4 bg-gray-50 rounded-md border border-gray-200 shadow-sm"
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-3">
<div>
<input
id="email"
type="email"
placeholder="Enter your email"
className="w-full px-3 py-2 border rounded-md text-sm"
{...register('email', {
required: 'Email is required',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: 'Invalid email',
},
})}
/>
{errors.email && (
<p className="text-xs text-red-500 mt-1">
{errors.email.message}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsOpen(false);
reset();
}}
className="text-xs px-3 py-1 bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="text-xs px-3 py-1 bg-blue-600 text-white rounded-md"
>
{loading ? 'Sending...' : 'Send'}
</button>
</div>
{message && (
<p className="text-xs text-center mt-1">{message}</p>
)}
</form>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
Step 6: Create Reset Password Page
// app/auth/reset-password/page.jsx
"use client"
import { useState, useEffect } from 'react'
import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { useAuth } from '@/hooks/useAuth'
import supabase from '@/libs/supabase/client'
export default function ResetPasswordPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirm, setShowConfirm] = useState(false)
const { resetPassword } = useAuth()
useEffect(() => {
supabase.auth.getUser().then(({ data, error }) => {
if (error || !data.user) {
setError('Invalid or expired reset link')
} else {
setError(null)
}
setLoading(false)
})
}, [])
const handleSubmit = async (e) => {
e.preventDefault()
if (password !== confirmPassword) {
alert('Passwords do not match')
return
}
try {
await supabase.auth.updateUser({ password })
alert({
title: 'Password Updated',
description: 'You can now log in',
})
window.location.href = '/auth'
} catch (error) {
alert({
variant: 'destructive',
title: 'Reset Failed',
description: error.message,
})
}
}
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<Loader2 className="h-12 w-12 animate-spin text-orange-500" />
</div>
)
}
if (error) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-4 text-center">
<h1 className="text-2xl font-bold text-red-500 mb-4">Error</h1>
<p className="text-gray-600">{error}</p>
<button
onClick={() => (window.location.href = '/auth')}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded"
>
Request New Reset Link
</button>
</div>
)
}
return (
<div className="min-h-screen flex items-center justify-center p-4 bg-gray-100">
<form onSubmit={handleSubmit} className="w-full max-w-md bg-white p-6 rounded shadow space-y-6">
<div className="text-center">
<h1 className="text-3xl font-bold">Reset Password</h1>
<p className="text-gray-600">Enter your new password</p>
</div>
{[{
label: 'New Password', id: 'password', value: password, onChange: setPassword, show: showPassword, toggle: setShowPassword
}, {
label: 'Confirm Password', id: 'confirm', value: confirmPassword, onChange: setConfirmPassword, show: showConfirm, toggle: setShowConfirm
}].map(({ label, id, value, onChange, show, toggle }) => (
<div key={id}>
<label htmlFor={id} className="block font-medium mb-1">{label}</label>
<div className="relative">
<input
id={id}
type={show ? 'text' : 'password'}
required
minLength={6}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full border px-3 py-2 rounded pr-10"
/>
<button
type="button"
onClick={() => toggle(!show)}
className="absolute right-3 top-2.5 text-gray-500"
aria-label="Toggle visibility"
>
{show ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
))}
<button
type="submit"
className="w-full bg-blue-600 text-white py-2 rounded font-semibold"
>
Reset Password
</button>
</form>
</div>
)
}
Step 7 : Create Callback for Auth Flow
// app/auth/callback/page.jsx
'use client'
import { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import supabase from '@/libs/supabase/client'
import { Loader2 } from 'lucide-react'
export default function AuthCallback() {
const router = useRouter()
useEffect(() => {
const handleRedirect = async () => {
const hash = window.location.hash
const params = new URLSearchParams(hash.slice(1))
const type = params.get('type')
const access_token = params.get('access_token')
if (type !== 'recovery' || !access_token) {
router.push('/auth?error=Invalid or expired link')
return
}
// Let Supabase auto-detect session from URL
await supabase.auth.getSession()
router.push('/auth/reset-password')
}
handleRedirect()
}, [router])
return (
<div className="min-h-screen flex items-center justify-center bg-black">
<Loader2 className="h-12 w-12 animate-spin text-orange-500" />
</div>
)
}
Step 8 : My Profile page
// app/myprofile/page.js
'use client'
import { useAuth } from '@/hooks/useAuth';
import supabase from '@/libs/supabase/client';
import Image from 'next/image';
import { useState } from 'react';
const ProfilePage = () => {
const { user, signOut } = useAuth();
const [editing, setEditing] = useState(false);
const [newUsername, setNewUsername] = useState(user?.user_metadata?.username || '');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
if (!user) return <p>Loading user data...</p>;
const email = user.email;
const avatar = user.user_metadata?.avatar_url || '/default-avatar.png';
const createdAt = new Date(user.created_at).toLocaleDateString();
const handleLogout = async () => {
try {
await signOut();
} catch (err) {
alert('Failed to logout!');
}
};
const handleUsernameUpdate = async () => {
setLoading(true);
setMessage('');
const { data: existing, error: checkErr } = await supabase
.from('users_view')
.select('id')
.eq('user_metadata->>username', newUsername);
if (checkErr) {
setMessage('Error checking username');
setLoading(false);
return;
}
if (existing.length > 0 && newUsername !== user.user_metadata?.username) {
setMessage('Username already taken.');
setLoading(false);
return;
}
const { error } = await supabase.auth.updateUser({
data: { username: newUsername },
});
if (error) {
setMessage('Failed to update username.');
} else {
setMessage('Username updated successfully!');
setEditing(false);
}
setLoading(false);
};
return (
<div className="min-h-screen flex flex-col items-center justify-center bg-gray-100 p-6">
<div className="bg-white rounded-2xl shadow-xl p-6 max-w-sm w-full text-center">
<Image
src={avatar}
alt="Profile Picture"
width={100}
height={100}
className="rounded-full mx-auto mb-4"
/>
{editing ? (
<div className="mb-3">
<input
type="text"
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
className="border px-3 py-1 rounded w-full"
placeholder="Enter new username"
/>
<div className="flex justify-center mt-2 gap-2">
<button
onClick={handleUsernameUpdate}
className="bg-green-500 text-white px-3 py-1 rounded hover:bg-green-600"
disabled={loading}
>
{loading ? 'Updating...' : 'Save'}
</button>
<button
onClick={() => {
setEditing(false);
setNewUsername(user.user_metadata?.username || '');
setMessage('');
}}
className="bg-gray-400 text-white px-3 py-1 rounded hover:bg-gray-500"
>
Cancel
</button>
</div>
</div>
) : (
<>
<h2 className="text-2xl font-bold mb-1">{user.user_metadata?.username || 'No username'}</h2>
<button
onClick={() => setEditing(true)}
className="text-sm text-blue-500 hover:underline mb-3"
>
Edit Username
</button>
</>
)}
{message && <p className="text-sm text-center text-gray-600 mb-2">{message}</p>}
<p className="text-gray-600 mb-1">{email}</p>
<p className="text-sm text-gray-500 mb-4">Member since: {createdAt}</p>
<button
onClick={handleLogout}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600 transition"
>
Logout
</button>
</div>
</div>
);
};
export default ProfilePage;
In this post, I walk you through a complete authentication flow in Next.js using JavaScript—including login, signup, forgot password, reset password, and Google provider integration. I’ve shared detailed implementation steps to help you build a secure and user-friendly auth system. Hope you find it useful and enjoyable!
Subscribe to my newsletter
Read articles from Suzune Horikita directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
