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:
- ✅ Email/password authentication with Supabase
- ✅ Protected routes with middleware
- ✅ User session management
- ✅ Sign up with email confirmation
- ✅ Beautiful UI with Tailwind CSS and shadcn/ui
- ✅ TypeScript for type safety
- ✅ Database integration with typed schemas
Why Supabase?
Supabase provides:
- Authentication: Built-in auth with multiple providers
- Database: PostgreSQL with real-time capabilities
- Security: Row Level Security (RLS) policies
- Developer Experience: Excellent TypeScript support
- Scalability: Enterprise-grade infrastructure
Prerequisites
- Node.js 18+ installed
- Basic knowledge of React and Next.js
- A Supabase account (free tier available)
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:
- Style: Default
- Base color: Slate
- CSS variables: Yes
Install the required UI components:
npx shadcn@latest add button input card tabs toast avatar dropdown-menu skeleton
Step 2: Supabase Project Setup
-
Create a Supabase Project
- Go to supabase.com
- Click “New Project”
- Choose your organization and project name
- Set a database password and region
-
Get Your Project Credentials
- Go to Settings → API
- Copy your Project URL and anon key
-
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
-
Go to Authentication → Settings in your Supabase dashboard
-
Configure Site URL:
- Add
http://localhost:3000
for development - Add your production domain for deployment
- Add
-
Configure Redirect URLs:
- Add
http://localhost:3000/auth/callback
for development - Add
https://yourdomain.com/auth/callback
for production
- Add
-
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:
- Home Page: Should display welcome message with auth buttons
- Sign Up: Create a new account (check email for confirmation)
- Sign In: Log in with existing credentials
- Dashboard: Protected page that requires authentication
- 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
- Row Level Security (RLS): Always enable RLS on your database tables
- Environment Variables: Never expose sensitive keys in client-side code
- Type Safety: Use TypeScript and generated database types
- HTTPS: Always use HTTPS in production
- Email Confirmation: Require email verification for new users
Troubleshooting
Common Issues
- “Invalid redirect URL”: Check your Supabase auth settings
- Session not persisting: Ensure middleware is properly configured
- TypeScript errors: Regenerate database types after schema changes
- 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:
- ✅ Secure user authentication with email/password
- ✅ Protected routes with automatic redirects
- ✅ Session management across page refreshes
- ✅ Beautiful, responsive UI components
- ✅ Type-safe database interactions
- ✅ Scalable architecture for any project type
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
- Add user profile management
- Implement role-based access control
- Add real-time features with Supabase subscriptions
- Set up automated testing for authentication flows
- Configure monitoring and analytics
Happy coding! 🚀