brianchitester.comPostsPolymath Profiles

Building a Modern Next.js App with Supabase Authentication

Authentication is a fundamental requirement for most web applications. Whether you’re building a learning platform, e-commerce site, or SaaS application, you need a robust, secure authentication system. In this comprehensive guide, we’ll build a complete Next.js application with Supabase authentication from scratch.

What We’re Building

By the end of this tutorial, you’ll have a fully functional Next.js application with:

Why Supabase?

Supabase provides:

Prerequisites

Step 1: Project Setup

Let’s start by creating a new Next.js project with TypeScript and the latest App Router:

npx create-next-app@latest my-auth-app --typescript --tailwind --eslint --app --src-dir=false cd my-auth-app

Install the required dependencies:

npm install @supabase/auth-helpers-nextjs @supabase/supabase-js npm install @radix-ui/react-slot class-variance-authority clsx tailwind-merge npm install lucide-react @hookform/resolvers react-hook-form zod

For UI components, we’ll use shadcn/ui. Initialize it:

npx shadcn@latest init

Configure components.json when prompted:

Install the required UI components:

npx shadcn@latest add button input card tabs toast avatar dropdown-menu skeleton

Step 2: Supabase Project Setup

  1. Create a Supabase Project

    • Go to supabase.com
    • Click “New Project”
    • Choose your organization and project name
    • Set a database password and region
  2. Get Your Project Credentials

    • Go to Settings → API
    • Copy your Project URL and anon key
  3. Configure Environment Variables

Create a .env.local file in your project root:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key NEXT_PUBLIC_SITE_URL=http://localhost:3000

Step 3: Create Supabase Clients

We need different Supabase clients for different contexts in Next.js:

Client-Side Supabase Client

Create lib/supabase/client.ts:

import { createClientComponentClient } from "@supabase/auth-helpers-nextjs"; import type { Database } from "@/lib/supabase/database.types"; // Create a single instance of the Supabase client to be used across the client components let supabaseClient: ReturnType< typeof createClientComponentClient<Database> > | null = null; export const getSupabaseClient = () => { if (!supabaseClient) { supabaseClient = createClientComponentClient<Database>(); } return supabaseClient; };

Server-Side Supabase Client

Create lib/supabase/server.ts:

// This file should ONLY be imported in Server Components in the app directory import { createServerComponentClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; import type { Database } from "@/lib/supabase/database.types"; export const getSupabaseServer = () => { const cookieStore = cookies(); const supabase = createServerComponentClient<Database>({ cookies: () => cookieStore, }); return supabase; };

Step 4: Generate Database Types

Supabase can automatically generate TypeScript types for your database schema:

npx supabase gen types typescript --project-id your_project_id > lib/supabase/database.types.ts

Create a basic types file if you haven’t set up your database schema yet:

// lib/supabase/database.types.ts export type Json = | string | number | boolean | null | { [key: string]: Json | undefined } | Json[]; export interface Database { public: { Tables: { user_progress: { Row: { id: number; user_id: string; created_at: string; updated_at: string; }; Insert: { id?: number; user_id: string; created_at?: string; updated_at?: string; }; Update: { id?: number; user_id?: string; created_at?: string; updated_at?: string; }; }; }; }; }

Step 5: Authentication Middleware

Create middleware.ts in your project root to handle authentication:

import { createMiddlewareClient } from "@supabase/auth-helpers-nextjs"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; import type { Database } from "@/lib/supabase/database.types"; export async function middleware(req: NextRequest) { const res = NextResponse.next(); const supabase = createMiddlewareClient<Database>({ req, res }); // Refresh session if expired await supabase.auth.getSession(); return res; } // Specify which paths this middleware should run on export const config = { matcher: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * - public (public files) */ "/((?!_next/static|_next/image|favicon.ico|public).*)", ], };

Step 6: Authentication Components

Create the Authentication Form

Create components/auth/auth-form.tsx:

"use client"; import { useState, useEffect } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import { Eye, EyeOff, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getSupabaseClient } from "@/lib/supabase/client"; export function AuthForm() { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState<string | null>(null); const [showPassword, setShowPassword] = useState(false); const [activeTab, setActiveTab] = useState("signin"); const router = useRouter(); const searchParams = useSearchParams(); const supabase = getSupabaseClient(); useEffect(() => { // Check if there's a tab parameter in the URL const tabParam = searchParams.get("tab"); if (tabParam === "signin" || tabParam === "signup") { setActiveTab(tabParam); } }, [searchParams]); const handleSignUp = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); try { const { error } = await supabase.auth.signUp({ email, password, options: { emailRedirectTo: `${window.location.origin}/auth/callback`, }, }); if (error) { setError(error.message); } else { setError("Check your email for the confirmation link."); } } catch (err) { setError("An unexpected error occurred."); console.error(err); } finally { setLoading(false); } }; const handleSignIn = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(null); try { const { data, error } = await supabase.auth.signInWithPassword({ email, password, }); if (error) { setError(error.message); } else if (data.user) { router.push("/dashboard"); } } catch (err) { setError("An unexpected error occurred."); console.error(err); } finally { setLoading(false); } }; return ( <Card className="w-full max-w-md mx-auto"> <Tabs value={activeTab} onValueChange={setActiveTab}> <CardHeader> <TabsList className="grid w-full grid-cols-2"> <TabsTrigger value="signin">Sign In</TabsTrigger> <TabsTrigger value="signup">Sign Up</TabsTrigger> </TabsList> </CardHeader> <CardContent> {error && ( <div className={`p-3 mb-4 text-sm rounded-md ${ error.includes("Check your email") ? "text-green-700 bg-green-50" : "text-red-500 bg-red-50" }`} > {error} </div> )} <TabsContent value="signin"> <form onSubmit={handleSignIn} className="space-y-4"> <div className="space-y-2"> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="space-y-2 relative"> <Input type={showPassword ? "text" : "password"} placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <Button type="button" variant="ghost" size="icon" className="absolute right-0 top-0 h-full" onClick={() => setShowPassword(!showPassword)} > {showPassword ? ( <EyeOff className="h-4 w-4" /> ) : ( <Eye className="h-4 w-4" /> )} </Button> </div> <Button type="submit" className="w-full" disabled={loading}> {loading ? ( <Loader2 className="mr-2 h-4 w-4 animate-spin" /> ) : ( "Sign In" )} </Button> </form> </TabsContent> <TabsContent value="signup"> <form onSubmit={handleSignUp} className="space-y-4"> <div className="space-y-2"> <Input type="email" placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} required /> </div> <div className="space-y-2 relative"> <Input type={showPassword ? "text" : "password"} placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} required /> <Button type="button" variant="ghost" size="icon" className="absolute right-0 top-0 h-full" onClick={() => setShowPassword(!showPassword)} > {showPassword ? ( <EyeOff className="h-4 w-4" /> ) : ( <Eye className="h-4 w-4" /> )} </Button> </div> <Button type="submit" className="w-full" disabled={loading}> {loading ? ( <Loader2 className="mr-2 h-4 w-4 animate-spin" /> ) : ( "Sign Up" )} </Button> </form> </TabsContent> </CardContent> </Tabs> </Card> ); }

User Profile Component

Create components/auth/user-profile.tsx:

"use client"; import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { LogOut, User } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { getSupabaseClient } from "@/lib/supabase/client"; import { Skeleton } from "@/components/ui/skeleton"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; export function UserProfile() { const [user, setUser] = useState<any>(null); const [loading, setLoading] = useState(true); const router = useRouter(); const supabase = getSupabaseClient(); useEffect(() => { const fetchUser = async () => { const { data } = await supabase.auth.getUser(); setUser(data.user); setLoading(false); }; fetchUser(); const { data: authListener } = supabase.auth.onAuthStateChange( (event, session) => { if (event === "SIGNED_IN") { setUser(session?.user || null); } else if (event === "SIGNED_OUT") { setUser(null); } } ); return () => { authListener.subscription.unsubscribe(); }; }, [supabase]); const handleSignOut = async () => { await supabase.auth.signOut(); router.push("/"); router.refresh(); }; if (loading) { return ( <div className="flex items-center space-x-2"> <Skeleton className="h-8 w-8 rounded-full" /> <Skeleton className="h-4 w-24" /> </div> ); } if (!user) { return ( <Button variant="outline" onClick={() => router.push("/auth")}> Sign In </Button> ); } return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" className="relative h-8 w-8 rounded-full"> <Avatar className="h-8 w-8"> <AvatarFallback> <User className="h-4 w-4" /> </AvatarFallback> </Avatar> </Button> </DropdownMenuTrigger> <DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuLabel className="font-normal"> <div className="flex flex-col space-y-1"> <p className="text-sm font-medium leading-none">{user.email}</p> <p className="text-xs leading-none text-muted-foreground"> Signed in </p> </div> </DropdownMenuLabel> <DropdownMenuSeparator /> <DropdownMenuItem onClick={handleSignOut}> <LogOut className="mr-2 h-4 w-4" /> <span>Log out</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> ); }

Step 7: Create Pages

Authentication Page

Create app/auth/page.tsx:

import { AuthForm } from "@/components/auth/auth-form"; export default function AuthPage() { return ( <div className="min-h-screen flex items-center justify-center bg-gray-50"> <div className="max-w-md w-full space-y-8"> <div className="text-center"> <h2 className="mt-6 text-3xl font-bold text-gray-900">Welcome</h2> <p className="mt-2 text-sm text-gray-600"> Sign in to your account or create a new one </p> </div> <AuthForm /> </div> </div> ); }

Auth Callback Handler

Create app/api/auth/callback/route.ts:

import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; import { cookies } from "next/headers"; import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { const requestUrl = new URL(request.url); const code = requestUrl.searchParams.get("code"); if (code) { const cookieStore = cookies(); const supabase = createRouteHandlerClient({ cookies: () => cookieStore }); try { await supabase.auth.exchangeCodeForSession(code); } catch (error) { console.error("Error exchanging code for session:", error); } } // Redirect to dashboard after successful authentication return NextResponse.redirect(`${requestUrl.origin}/dashboard`); }

Protected Dashboard Page

Create app/dashboard/page.tsx:

import { redirect } from "next/navigation"; import { getSupabaseServer } from "@/lib/supabase/server"; import { UserProfile } from "@/components/auth/user-profile"; export default async function DashboardPage() { const supabase = getSupabaseServer(); const { data: { session }, } = await supabase.auth.getSession(); // If no session, redirect to auth page if (!session) { redirect("/auth?message=Please sign in to view your dashboard"); } return ( <div className="min-h-screen bg-gray-50"> <header className="bg-white shadow"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex justify-between h-16"> <div className="flex items-center"> <h1 className="text-xl font-semibold">Dashboard</h1> </div> <div className="flex items-center"> <UserProfile /> </div> </div> </div> </header> <main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> <div className="px-4 py-6 sm:px-0"> <div className="border-4 border-dashed border-gray-200 rounded-lg h-96 flex items-center justify-center"> <div className="text-center"> <h2 className="text-2xl font-bold text-gray-900 mb-2"> Welcome to your Dashboard! </h2> <p className="text-gray-600"> You are successfully authenticated with Supabase. </p> <p className="text-sm text-gray-500 mt-2"> User ID: {session.user.id} </p> <p className="text-sm text-gray-500"> Email: {session.user.email} </p> </div> </div> </div> </main> </div> ); }

Home Page

Update app/page.tsx:

import Link from "next/link"; import { Button } from "@/components/ui/button"; export default function HomePage() { return ( <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <div className="flex flex-col justify-center min-h-screen"> <div className="text-center"> <h1 className="text-4xl font-bold text-gray-900 sm:text-5xl md:text-6xl"> Welcome to <span className="text-indigo-600">Your App</span> </h1> <p className="mt-3 max-w-md mx-auto text-base text-gray-500 sm:text-lg md:mt-5 md:text-xl md:max-w-3xl"> A modern web application built with Next.js and Supabase authentication. </p> <div className="mt-5 max-w-md mx-auto sm:flex sm:justify-center md:mt-8"> <div className="rounded-md shadow"> <Link href="/dashboard"> <Button className="w-full">Get Started</Button> </Link> </div> <div className="mt-3 rounded-md shadow sm:mt-0 sm:ml-3"> <Link href="/auth"> <Button variant="outline" className="w-full"> Sign In </Button> </Link> </div> </div> </div> </div> </div> </div> ); }

Step 8: Configure Authentication in Supabase

  1. Go to Authentication → Settings in your Supabase dashboard

  2. Configure Site URL:

    • Add http://localhost:3000 for development
    • Add your production domain for deployment
  3. Configure Redirect URLs:

    • Add http://localhost:3000/auth/callback for development
    • Add https://yourdomain.com/auth/callback for production
  4. Enable Email Templates (Optional):

    • Customize the email confirmation template
    • Configure password reset emails

Step 9: Database Schema (Optional)

If you want to store user-specific data, create some tables. Go to the SQL Editor in Supabase and run:

-- Create a table for user progress create table user_progress ( id bigint generated by default as identity primary key, user_id uuid references auth.users not null, created_at timestamp with time zone default timezone('utc'::text, now()) not null, updated_at timestamp with time zone default timezone('utc'::text, now()) not null, total_score integer default 0, level integer default 1 ); -- Set up Row Level Security (RLS) alter table user_progress enable row level security; -- Create policy so users can only see their own data create policy "Users can view own progress" on user_progress for select using (auth.uid() = user_id); create policy "Users can insert own progress" on user_progress for insert with check (auth.uid() = user_id); create policy "Users can update own progress" on user_progress for update using (auth.uid() = user_id);

Step 10: Running the Application

Start your development server:

npm run dev

Navigate to http://localhost:3000 and test your authentication flow:

  1. Home Page: Should display welcome message with auth buttons
  2. Sign Up: Create a new account (check email for confirmation)
  3. Sign In: Log in with existing credentials
  4. Dashboard: Protected page that requires authentication
  5. Sign Out: Should redirect back to home page

Advanced Features

Password Reset

Add password reset functionality to your auth form:

const handlePasswordReset = async (email: string) => { const { error } = await supabase.auth.resetPasswordForEmail(email, { redirectTo: `${window.location.origin}/auth/reset-password`, }); if (error) { setError(error.message); } else { setError("Check your email for the password reset link."); } };

Social Authentication

Enable OAuth providers in Supabase and add social login:

const handleGoogleSignIn = async () => { const { error } = await supabase.auth.signInWithOAuth({ provider: "google", options: { redirectTo: `${window.location.origin}/auth/callback`, }, }); if (error) console.error("Error:", error.message); };

User Metadata

Store additional user information:

const { error } = await supabase.auth.signUp({ email, password, options: { data: { first_name: "John", last_name: "Doe", username: "johndoe", }, }, });

Deployment

Environment Variables for Production

Add these environment variables to your hosting platform:

NEXT_PUBLIC_SUPABASE_URL=your_supabase_project_url NEXT_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key NEXT_PUBLIC_SITE_URL=https://yourdomain.com

Vercel Deployment

# Install Vercel CLI npm i -g vercel # Deploy vercel # Set environment variables vercel env add NEXT_PUBLIC_SUPABASE_URL vercel env add NEXT_PUBLIC_SUPABASE_ANON_KEY vercel env add NEXT_PUBLIC_SITE_URL

Security Best Practices

  1. Row Level Security (RLS): Always enable RLS on your database tables
  2. Environment Variables: Never expose sensitive keys in client-side code
  3. Type Safety: Use TypeScript and generated database types
  4. HTTPS: Always use HTTPS in production
  5. Email Confirmation: Require email verification for new users

Troubleshooting

Common Issues

  1. “Invalid redirect URL”: Check your Supabase auth settings
  2. Session not persisting: Ensure middleware is properly configured
  3. TypeScript errors: Regenerate database types after schema changes
  4. CORS issues: Check your site URL configuration in Supabase

Debug Tools

Add this component to debug authentication state:

"use client"; import { useEffect, useState } from "react"; import { getSupabaseClient } from "@/lib/supabase/client"; export function AuthDebug() { const [session, setSession] = useState<any>(null); const supabase = getSupabaseClient(); useEffect(() => { const getSession = async () => { const { data } = await supabase.auth.getSession(); setSession(data.session); }; getSession(); const { data: authListener } = supabase.auth.onAuthStateChange( (event, session) => { setSession(session); console.log("Auth state change:", event, session); } ); return () => { authListener.subscription.unsubscribe(); }; }, [supabase]); return ( <div className="fixed bottom-4 right-4 bg-gray-900 text-white p-4 rounded-lg text-xs max-w-xs"> <h3 className="font-bold mb-2">Auth Debug</h3> <pre> {JSON.stringify( { session: !!session, user: session?.user?.email }, null, 2 )} </pre> </div> ); }

Conclusion

You now have a complete, production-ready authentication system using Next.js and Supabase! This setup provides:

This foundation can be extended for any application that needs user authentication - whether it’s an e-learning platform, e-commerce site, SaaS application, or social platform. The modular architecture makes it easy to add features like user profiles, role-based access control, team management, and more.

Next Steps

Happy coding! 🚀

2025 © Brian Chitester.