Ah, authentication - the digital equivalent of a bouncer at an exclusive club, but instead of checking IDs, we're validating JWT tokens! π«
Let's face it: building authentication from scratch is about as fun as debugging production code at 3 AM. But fear not, fellow developer! With Next.js and Supabase in your toolkit, you're about to become the authentication wizard you always dreamed of being. β¨
Picture this: You've built an amazing Next.js application that's going to revolutionize the world (or at least make your portfolio look cooler). But then comes the dreaded requirement: "We need user authentication." Suddenly, you're drowning in a sea of:
- Session management headaches π€
- JWT tokens that expire faster than your coffee gets cold
- Protected routes that feel more like a maze than a security feature
- OAuth flows that make you question your career choices
- User data that needs to be more secure than your grandmother's secret cookie recipe
And let's not even talk about the horror stories of developers who tried to roll their own authentication system. shudders
Enter Next.js and Supabase - the dynamic duo that's about to make authentication as smooth as your favorite JavaScript framework. In this guide, you'll learn how to implement a production-ready authentication system faster than you can say "bcrypt."
By the end of this guide, you'll be able to:
- Set up a bulletproof authentication system that even your security-obsessed tech lead will approve of
- Implement email/password authentication that actually works (and handles those pesky edge cases)
- Add social authentication providers because who wants to remember another password?
- Manage user sessions without losing your sanity
- Protect your routes like a medieval castle (but with better UX)
- Handle authentication errors gracefully (because users do weird things)
You might be wondering, "Why this stack?" Well:
- Next.js: The React framework that makes server-side rendering feel like a walk in the park
- Supabase: The open-source Firebase alternative that gives you:
- A PostgreSQL database (because real developers use SQL π)
- Built-in authentication that just worksβ’
- Real-time subscriptions
- Edge functions that run faster than your caffeine-induced typing speed
The best part? You get all of this without selling your soul to a tech giant or mortgaging your side project's future.
"But how long will this take?" I hear you ask. Well, grab your favorite caffeinated beverage because in about 30 minutes, you'll have:
- A working authentication system
- Protected routes
- User management
- Social login options
- And enough time left over to explain to your project manager why "just adding a small feature" actually takes three sprints
Ready to become an auth expert? Let's dive in! But first, make sure you've got all the prerequisites ready in the next section. Trust me, you don't want to be that developer who starts coding only to realize they forgot to install Node.js. π€¦ββοΈ
π Table of Contents
Skip to Deployment Guide
Let's get our development environment ready. And no, "it works on my machine" won't cut it this time. π―
Node.js (v18.0.0 or higher)
- Because we're professionals who keep our runtime environments updated
- Use nvm if you're juggling multiple Node versions (trust me, future you will thank me)
Code Editor
- VS Code recommended (with these essential extensions):
- ESLint (for catching those "interesting" code patterns)
- Prettier (because life's too short for formatting debates)
- TypeScript Extension (your new best friend for catching type errors)
Supabase Account
- Free tier is perfectly fine
- You'll need to create a new project
- Keep those API keys handy (and please, not in a text file on your desktop)
TypeScript Knowledge
- Basic understanding of types and interfaces
- If you think
any
is a solution, this guide might hurt your feelings
Next.js Fundamentals
- App Router understanding
- Server Components vs Client Components
- Basic routing knowledge
- Data fetching patterns
Git
- For version control
- Because "final-final-v3-real-final.js" is not a version control strategy
Environment Variables
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
Keep these safe - they're like your house keys, but for your database
Package Manager
- npm, yarn, or pnpm (pick your poison)
- Just be consistent - package manager drama isn't worth your time
- Docker (for local development parity)
- A decent understanding of JWT tokens
- Basic SQL knowledge (because sometimes, you need to speak directly to Postgres)
- A reliable internet connection (hot-spotting from your phone isn't ideal for development)
Take a moment to verify your setup. Here's a quick sanity check:
node --version # Should be β₯ 18.0.0
npm --version # Latest stable version
git --version # You'd be surprised how often this is missing
If everything checks out, you're ready to proceed. If not, fix your setup now - it's easier than debugging mysterious errors later.
Phew! That was a lot of setup, right? But trust me, having a solid foundation is like having a good cup of coffee before coding - it makes everything better! Now that we've got our development environment ready to rock, let's dive into the fun part - building our authentication system! π
Let's build our authentication system with a solid foundation. We'll use Next.js 15 with the App Router, TypeScript, and Server Components - because we're building for the future. π
First, let's scaffold our Next.js project with the official starter:
pnpm dlx create-next-app@latest auth-nextjs-supabase
When prompted, you can accept the defaults, but make sure these are selected:
β Would you like to use TypeScript? Yes
β Would you like to use ESLint? Yes
β Would you like to use Tailwind CSS? Yes
β Would you like your code inside a `src/` directory? Yes
β Would you like to use App Router? (recommended) Yes
β Would you like to use Turbopack for `next dev`? Yes
β Would you like to customize the import alias (`@/*` by default)? No
Let's organize our project with a scalable structure:
src/
βββ app/
β βββ (auth-pages)/
β β βββ login/
β β β βββ page.tsx
β β βββ register/
β β β βββ page.tsx
β β βββ reset-password/
β β β βββ page.tsx
β β βββ layout.tsx
β βββ auth/
β β βββ callback/
β β βββ route.ts
β βββ (protected-pages)/
β β βββ dashboard/
β β β βββ page.tsx
β β βββ profile/
β β β βββ page.tsx
β β βββ layout.tsx
β βββ api/
β β βββ auth/
β β β βββ login/
β β β β βββ route.ts
β β β βββ register/
β β β β βββ route.ts
β β β βββ reset-password/
β β β βββ route.ts
β β βββ profile/
β β βββ route.ts
β βββ layout.tsx
βββ components/
β βββ auth/
β β βββ login-form.tsx
β β βββ register-form.tsx
β β βββ password-reset-form.tsx
β βββ ui/
βββ hooks/
β βββ use-auth-login.ts
β βββ use-auth-register.ts
β βββ use-auth-reset.ts
β βββ use-profile.ts
βββ lib/
β βββ api/
β β βββ with-auth.ts
β βββ providers/
β β βββ auth-provider.tsx
β βββ stores/
β β βββ use-auth-store.ts
β βββ supabase/
β β βββ client.ts
β β βββ server.ts
β β βββ types.ts
β βββ utils/
β β βββ auth-utils.ts
β β βββ rate-limit.ts
β βββ validations/
β βββ auth.ts
βββ middleware.ts
βββ types/
βββ env.d.ts
Let's add our essential packages:
pnpm add @supabase/ssr @supabase/supabase-js @hookform/resolvers react-hook-form zod zustand
pnpm add -D @types/node @types/react @types/react-dom typescript
Create a .env.local
file in your project root:
NEXT_PUBLIC_SUPABASE_URL="your-project-url"
NEXT_PUBLIC_SUPABASE_ANON_KEY="your-anon-key"
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
- Head to Supabase Dashboard
- Create a new project
- Navigate to Authentication -> Url configuration
- Add the following url to the site url:
http://localhost:3000/auth/callback
Update your tsconfig.json
to include strict type checking and paths:
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}
Create a types file at src/lib/supabase/types.ts
:
// src/lib/supabase/types.ts
export type Database = {
public: {
Tables: {
profiles: {
Row: {
id: string;
updated_at: string;
username: string | null;
full_name: string | null;
avatar_url: string | null;
email: string;
};
Insert: {
id: string;
updated_at?: string;
username?: string | null;
full_name?: string | null;
avatar_url?: string | null;
email: string;
};
Update: {
id?: string;
updated_at?: string;
username?: string | null;
full_name?: string | null;
avatar_url?: string | null;
email?: string;
};
};
};
};
};
- Updated dependencies to their latest versions
Update your
next.config.js
:
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: ["localhost", "your-supabase-project.supabase.co"],
},
};
module.exports = nextConfig;
Let's modify the landing page with our own content:
// src/app/page.tsx
import { Button } from "@/components/ui/button";
import { CodeIcon, LockIcon, UsersIcon } from "lucide-react";
import Link from "next/link";
export default function Home() {
return (
<div className="min-h-screen">
{/* Hero Section */}
<section className="py-20 px-6 md:px-20 space-y-10">
<div className="max-w-4xl mx-auto text-center space-y-6">
<h1 className="text-4xl md:text-6xl font-bold tracking-tight">
Modern Authentication for{" "}
<span className="text-primary">Next.js Applications</span>
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
A secure, fast, and developer-friendly authentication solution built
with Next.js, Supabase, and modern web technologies.
</p>
<div className="flex gap-4 justify-center">
<Button size="lg" asChild>
<Link href="/register">Get Started</Link>
</Button>
<Button size="lg" variant="outline" asChild>
<Link href="/login">Sign In</Link>
</Button>
</div>
</div>
</section>
{/* Features Section */}
<section className="py-20 px-6 md:px-20 bg-muted/50">
<div className="max-w-6xl mx-auto">
<h2 className="text-3xl font-bold text-center mb-12">
Everything You Need
</h2>
<div className="grid md:grid-cols-3 gap-8">
{features.map((feature) => (
<div
key={feature.title}
className="p-6 rounded-lg bg-background shadow-sm"
>
<div className="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center mb-4">
<feature.icon className="w-6 h-6 text-primary" />
</div>
<h3 className="font-semibold mb-2">{feature.title}</h3>
<p className="text-muted-foreground">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="py-20 px-6 md:px-20">
<div className="max-w-4xl mx-auto text-center space-y-6">
<h2 className="text-3xl font-bold">Ready to Get Started?</h2>
<p className="text-xl text-muted-foreground">
Join thousands of developers building secure applications
</p>
<Button size="lg" asChild>
<Link href="/register">Create Your Account</Link>
</Button>
</div>
</section>
</div>
);
}
const features = [
{
title: "Secure Authentication",
description:
"Enterprise-grade security with Supabase Auth, including JWT tokens and secure password hashing.",
icon: LockIcon,
},
{
title: "Modern Stack",
description:
"Built with Next.js 15, TypeScript, and Tailwind CSS for a modern development experience.",
icon: CodeIcon,
},
{
title: "User Management",
description:
"Complete user management system with profile updates, password reset, and more.",
icon: UsersIcon,
},
];
Now we have a solid foundation with:
- Modern Next.js 15 setup with App Router
- Type-safe Supabase configuration
- Proper project structure for auth flows
- Essential dependencies for form handling and validation
- Environment configuration for different deployment stages
Look at that beautiful project structure! π It's like Marie Kondo came in and organized our code. Now that we've got our project scaffolded and organized, let's add some Supabase magic to the mix. Grab your wand (or keyboard), and let's continue!
Let's set up our Supabase client with proper TypeScript support and handle both server and client-side configurations. We'll create a clean, maintainable structure that works seamlessly with Next.js 15's Server Components.
First, let's create our environment type definitions to ensure type safety throughout our application. This will help catch any environment variable issues early in development:
// src/types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
NEXT_PUBLIC_SUPABASE_URL: string
NEXT_PUBLIC_SUPABASE_ANON_KEY: string
SUPABASE_SERVICE_ROLE_KEY: string
}
}
Now that our environment types are set up, let's create separate client configurations for server and browser environments. We'll start with the server client:
// src/lib/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { Database } from "./types";
export async function createServerSupabaseClient() {
const cookieStore = await cookies();
return createServerClient<Database>(
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 (error) {
console.error("Error setting cookies:", error);
}
},
},
}
);
}
Next, let's implement the browser client. This will handle all client-side Supabase operations:
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
import { Database } from './types'
export function createClientSupabaseClient() {
return createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
)
}
With our Supabase clients ready, we need to set up middleware to handle authentication and protect our routes. This middleware will:
- Check authentication status
- Handle protected route access
- Manage redirects for authenticated/unauthenticated users
// src/middleware.ts
import { createServerSupabaseClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
const response = NextResponse.next({
request: {
headers: request.headers,
},
})
const supabase = await createServerSupabaseClient()
const {
data: { session },
} = await supabase.auth.getSession()
// Protected routes pattern
const isProtectedRoute = request.nextUrl.pathname.startsWith('/profile')
const isAuthRoute = request.nextUrl.pathname.startsWith('/auth')
if (isProtectedRoute && !session) {
return NextResponse.redirect(new URL('/auth/login', request.url))
}
if (isAuthRoute && session) {
return NextResponse.redirect(new URL('/dashboard', request.url))
}
return response
}
export const config = {
matcher: [
"/api/:path*",
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
}
The final piece of our Supabase setup is the callback route. This handles:
- Email confirmation flows
- OAuth provider redirects
- Password reset completions
// src/app/auth/callback/route.ts
import { createServerSupabaseClient } from "@/lib/supabase/server"
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 supabase = await createServerSupabaseClient()
await supabase.auth.exchangeCodeForSession(code)
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(new URL('/dashboard', request.url))
}
High five! β We've got our Supabase client set up and ready to authenticate users like a boss. But a client without proper state management is like a car without an engine - looks nice, but won't get you far. Let's fix that!
Let's break down our state management implementation into logical pieces:
First, we'll create our Zustand store to manage authentication state. This store will:
- Hold the current user state
- Handle loading states
// src/lib/stores/use-auth-store.ts
import { User } from '@supabase/supabase-js'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
interface AuthState {
user: User | null
isLoading: boolean
setUser: (user: User | null) => void
setLoading: (isLoading: boolean) => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isLoading: true,
setUser: (user) => set({ user }),
setLoading: (isLoading) => set({ isLoading }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => sessionStorage),
}
)
)
Now we'll create a provider component that connects our Zustand store with Supabase's auth state:
// src/lib/providers/auth-provider.tsx
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { createClientSupabaseClient } from "@/lib/supabase/client";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { setUser, setLoading } = useAuthStore()
const supabase = createClientSupabaseClient()
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setUser(session.user);
}
setLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_, session) => {
setUser(session?.user ?? null)
setLoading(false)
})
return () => {
subscription.unsubscribe()
}
}, [setUser, setLoading])
return <>{children}</>;
}
To make route protection reusable, let's create a custom hook:
// src/hooks/use-protected-route.ts
import { useAuthStore } from '@/lib/stores/use-auth-store'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
interface UseProtectedRouteOptions {
redirectTo?: string
}
export function useProtectedRoute(options: UseProtectedRouteOptions = {}) {
const { redirectTo = '/auth/login' } = options
const { user, isLoading } = useAuthStore()
const router = useRouter()
useEffect(() => {
if (!isLoading && !user) {
router.replace(redirectTo)
}
}, [user, isLoading, redirectTo, router])
return { isLoading, user }
}
Finally, let's implement some helpful utility functions for common auth operations:
// src/lib/utils/auth-utils.ts
import { createClientSupabaseClient } from "@/lib/supabase/client";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
export function useSignOut() {
const router = useRouter();
const { setLoading, clearSession } = useAuthStore();
const supabase = createClientSupabaseClient();
const signOut = useCallback(async () => {
try {
setLoading(true);
await supabase.auth.signOut();
clearSession();
router.replace("/login");
router.refresh();
} catch (error) {
console.error("Error signing out:", error);
} finally {
setLoading(false);
}
}, [supabase, clearSession, setLoading, router]);
return signOut;
}
export function useAuthSession() {
const { user, isLoading } = useAuthStore();
return {
session: user ? { user } : null,
isLoading,
};
}
Update your root layout to include the AuthProvider:
// src/app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { AuthProvider } from "@/lib/providers/auth-provider";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<AuthProvider>{children}</AuthProvider>
</body>
</html>
);
}
Before implementing our API routes, let's set up our validation schemas to ensure data integrity:
This schema will validate all our authentication-related forms:
// src/lib/validations/auth.ts
import * as z from 'zod'
export const loginSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password is too long'),
})
export const registerSchema = z.object({
email: z.string().email('Please enter a valid email address'),
password: z
.string()
.min(8, 'Password must be at least 8 characters')
.max(100, 'Password is too long'),
confirmPassword: z.string(),
}).refine((data) => data.password === data.confirmPassword, {
message: "Passwords don't match",
path: ["confirmPassword"],
})
export const resetPasswordSchema = z.object({
email: z.string().email('Please enter a valid email address'),
})
export type LoginFormData = z.infer<typeof loginSchema>
export type RegisterFormData = z.infer<typeof registerSchema>
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>
Next, let's create our profile validation schema for user data:
// src/lib/validations/profile.ts
import * as z from 'zod'
export const profileSchema = z.object({
full_name: z.string().min(2, 'Full name must be at least 2 characters'),
username: z.string().min(3, 'Username must be at least 3 characters'),
avatar_url: z.string().url().optional().or(z.literal('')),
})
export type ProfileFormData = z.infer<typeof profileSchema>
Let's implement our API routes one by one, starting with login:
// src/app/api/auth/login/route.ts
import { createServerSupabaseClient } from "@/lib/supabase/server"
import { loginSchema } from "@/lib/validations/auth"
import { NextResponse } from "next/server"
import { type NextRequest } from "next/server"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validatedData = loginSchema.parse(body)
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase.auth.signInWithPassword({
email: validatedData.email,
password: validatedData.password,
})
if (error) throw error
return NextResponse.json({ data })
} catch (error) {
console.log(error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to login" },
{ status: 400 }
)
}
}
For registration, we'll need to handle email confirmation flows:
// src/app/api/auth/register/route.ts
import { createServerSupabaseClient } from "@/lib/supabase/server"
import { registerSchema } from "@/lib/validations/auth"
import { NextResponse } from "next/server"
import { type NextRequest } from "next/server"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validatedData = registerSchema.parse(body)
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase.auth.signUp({
email: validatedData.email,
password: validatedData.password,
options: {
emailRedirectTo: `${request.nextUrl.origin}/auth/callback`,
},
})
if (error) throw error
return NextResponse.json({ data })
} catch (error) {
console.log(error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to register" },
{ status: 400 }
)
}
}
The password reset route will handle sending reset emails:
// src/app/api/auth/reset-password/route.ts
import { createServerSupabaseClient } from "@/lib/supabase/server"
import { resetPasswordSchema } from "@/lib/validations/auth"
import { NextResponse } from "next/server"
import { type NextRequest } from "next/server"
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const validatedData = resetPasswordSchema.parse(body)
const supabase = await createServerSupabaseClient()
const { data, error } = await supabase.auth.resetPasswordForEmail(validatedData.email, {
redirectTo: `${request.nextUrl.origin}/auth/callback`,
})
if (error) throw error
return NextResponse.json({ data })
} catch (error) {
console.log(error)
return NextResponse.json(
{ error: error instanceof Error ? error.message : "Failed to register" },
{ status: 400 }
)
}
}
Create a middleware helper for protected routes:
// src/lib/api/with-auth.ts
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { User } from "@supabase/supabase-js";
import { NextResponse } from "next/server";
import { type NextRequest } from "next/server";
export async function withAuth(
request: NextRequest,
handler: (user: User | null) => Promise<NextResponse>
) {
try {
const supabase = await createServerSupabaseClient();
const {
data: { user },
error,
} = await supabase.auth.getUser();
if (error || !user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return await handler(user);
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Internal Server Error" },
{ status: 500 }
);
}
}
Next, we will create a profile API route:
// src/app/api/profile/route.ts
import { withAuth } from "@/lib/api/with-auth";
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { profileSchema } from "@/lib/validations/profile";
import { NextResponse } from "next/server";
import { type NextRequest } from "next/server";
export async function GET(request: NextRequest) {
return withAuth(request, async (user) => {
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return NextResponse.json({ data: user });
});
}
export async function PUT(request: NextRequest) {
return withAuth(request, async () => {
try {
const body = await request.json();
const validatedData = profileSchema.parse(body);
const supabase = await createServerSupabaseClient();
const { error } = await supabase.auth.updateUser({
data: {
...validatedData,
},
});
if (error) throw error;
return NextResponse.json({ message: "Profile updated successfully" });
} catch (error) {
console.error(error);
return NextResponse.json(
{ error: "Failed to update profile" },
{ status: 500 }
);
}
});
}
These additions provide:
- Validation schemas for authentication and profile
- Protected API routes with session validation
- Reusable authentication middleware
- Type-safe API handlers
- Proper error handling
- Clean separation of concerns
Our authentication state is now as solid as a rock! πͺ¨ But what good is authentication without some beautiful UI components to show it off? Let's make our auth system not just secure, but also a pleasure to look at!
Remember those login forms that look like they were designed in the 90s? Yeah, we're not doing that. We're building something that would make even design Twitter nod in approval. With Shadcn UI in our toolkit, we're about to create components that are both beautiful AND functional - because who said security can't be stylish? π
First, let's initialize Shadcn UI in our Next.js project:
pnpm dlx shadcn@latest init
When prompted, choose these options:
Would you like to use TypeScript (recommended)? yes
Which style would you like to use? βΊ Default
Which color would you like to use as base color? βΊ Slate
Where is your global CSS file? βΊ src/app/globals.css
Do you want to use CSS variables for colors? βΊ yes
Where is your tailwind.config.js located? βΊ tailwind.config.ts
Configure the import alias for components: βΊ @/components
Configure the import alias for utils: βΊ @/lib/utils
Are you using React Server Components? βΊ yes
Install the components we'll need for authentication:
pnpm dlx shadcn@latest add button card form input label toast
Create a new directory structure for our auth components:
src/components/
βββ auth/
β βββ auth-form.tsx
β βββ login-form.tsx
β βββ register-form.tsx
β βββ password-reset-form.tsx
βββ ui/
βββ auth-card.tsx
First, let's create our auth card component for consistent styling:
// src/components/ui/auth-card.tsx
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
interface AuthCardProps {
title: string
description: string
children: React.ReactNode
}
export function AuthCard({ title, description, children }: AuthCardProps) {
return (
<Card className="w-full max-w-md mx-auto">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
)
}
Let's create custom hooks for our authentication operations:
// src/hooks/use-auth-login.ts
import { useToast } from "@/hooks/use-toast";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { LoginFormData } from "@/lib/validations/auth";
import { useRouter } from "next/navigation";
interface UseAuthLogin {
isLoading: boolean;
login: (data: LoginFormData) => Promise<void>;
}
export function useAuthLogin(): UseAuthLogin {
const { setUser, setLoading, isLoading } = useAuthStore();
const { toast } = useToast();
const router = useRouter();
async function login(data: LoginFormData) {
try {
setLoading(true);
const response = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
const { data: responseData } = await response.json();
setUser(responseData.user);
toast({
title: "Success",
description: "You have successfully logged in.",
});
router.push("/dashboard");
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: error instanceof Error ? error.message : "Failed to login",
});
} finally {
setLoading(false);
}
}
return { isLoading, login };
}
// src/hooks/use-auth-register.ts
import { useToast } from "@/hooks/use-toast"
import { RegisterFormData } from "@/lib/validations/auth"
import { useRouter } from "next/navigation"
import { useState } from "react"
interface UseAuthRegister {
isLoading: boolean
register: (data: RegisterFormData) => Promise<void>
}
export function useAuthRegister(): UseAuthRegister {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
async function register(data: RegisterFormData) {
try {
setIsLoading(true);
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
if (!response.ok) {
const error = await response.json()
throw new Error(error.error)
}
toast({
title: "Success",
description: "You have successfully registered.",
})
router.push("/dashboard")
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: error instanceof Error ? error.message : "Failed to register",
})
} finally {
setIsLoading(false)
}
}
return { isLoading, register }
}
// src/hooks/use-auth-reset.ts
import { useToast } from "@/hooks/use-toast";
import { ResetPasswordFormData } from "@/lib/validations/auth";
import { useRouter } from "next/navigation";
import { useState } from "react";
interface UseAuthReset {
isLoading: boolean;
resetPassword: (data: ResetPasswordFormData) => Promise<void>;
}
export function useAuthReset(): UseAuthReset {
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
async function resetPassword(data: ResetPasswordFormData) {
try {
setIsLoading(true);
const response = await fetch("/api/auth/reset-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error);
}
toast({
title: "Success",
description: "Check your email for the password reset link.",
});
router.push("/auth/login");
router.refresh();
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error ? error.message : "Failed to send reset email",
});
} finally {
setIsLoading(false);
}
}
return { isLoading, resetPassword };
}
Now let's create our form components to use these hooks:
// src/components/auth/login-form.tsx
"use client";
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { LoginFormData, loginSchema } from "@/lib/validations/auth"
import { useAuthLogin } from "@/hooks/use-auth-login"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
export function LoginForm() {
const { isLoading, login } = useAuthLogin()
const form = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(login)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>
</form>
</Form>
)
}
// src/components/auth/register-form.tsx
"use client";
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { RegisterFormData, registerSchema } from "@/lib/validations/auth"
import { useAuthRegister } from "@/hooks/use-auth-register"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import Link from "next/link"
export function RegisterForm() {
const { isLoading, register } = useAuthRegister()
const form = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
email: "",
password: "",
confirmPassword: "",
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(register)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
<div className="text-sm text-center text-muted-foreground">
Already have an account?{" "}
<Link href="/auth/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</form>
</Form>
)
}
// src/components/auth/password-reset-form.tsx
"use client";
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { ResetPasswordFormData, resetPasswordSchema } from "@/lib/validations/auth"
import { useAuthReset } from "@/hooks/use-auth-reset"
import { Button } from "@/components/ui/button"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import Link from "next/link"
export function PasswordResetForm() {
const { isLoading, resetPassword } = useAuthReset()
const form = useForm<ResetPasswordFormData>({
resolver: zodResolver(resetPasswordSchema),
defaultValues: {
email: "",
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(resetPassword)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="you@example.com" type="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Sending reset link..." : "Send reset link"}
</Button>
<div className="text-sm text-center space-x-1 text-muted-foreground">
<Link href="/auth/login" className="text-primary hover:underline">
Back to login
</Link>
</div>
</form>
</Form>
)
}
Let's also create the page components that will use these forms:
// src/app/(auth-pages)/login/page.tsx
import { AuthCard } from "@/components/ui/auth-card"
import { LoginForm } from "@/components/auth/login-form"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Login",
description: "Login to your account",
}
export default function LoginPage() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<AuthCard
title="Welcome back"
description="Enter your email to sign in to your account"
>
<LoginForm />
</AuthCard>
</div>
)
}
// src/app/(auth-pages)/register/page.tsx
import { AuthCard } from "@/components/ui/auth-card"
import { RegisterForm } from "@/components/auth/register-form"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Register",
description: "Create a new account",
}
export default function RegisterPage() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<AuthCard
title="Create an account"
description="Enter your email below to create your account"
>
<RegisterForm />
</AuthCard>
</div>
)
}
// src/app/(auth-pages)/reset-password/page.tsx
import { AuthCard } from "@/components/ui/auth-card"
import { PasswordResetForm } from "@/components/auth/password-reset-form"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Reset Password",
description: "Reset your password",
}
export default function ResetPasswordPage() {
return (
<div className="flex h-screen w-screen flex-col items-center justify-center">
<AuthCard
title="Reset password"
description="Enter your email address and we will send you a reset link"
>
<PasswordResetForm />
</AuthCard>
</div>
)
}
And finally, let's create a layout for our auth pages:
// src/app/(auth-pages)/layout.tsx
import { createServerSupabaseClient } from "@/lib/supabase/server"
import { redirect } from "next/navigation"
export default async function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createServerSupabaseClient()
const { data: { session } } = await supabase.auth.getSession()
if (session) {
redirect("/dashboard")
}
return (
<div className="min-h-screen bg-background">
{children}
</div>
)
}
These components provide:
- Clean separation of concerns with custom hooks
- Consistent styling using Shadcn UI
- Type-safe form handling with Zod
- Proper error handling and loading states
- Responsive layouts
- SEO-friendly metadata
- Server-side authentication checks
- Proper navigation between auth pages
In the next section, we'll implement protected pages including the layout component and individual pages for the dashboard and profile.
Create a layout component for protected routes:
// src/app/(protected-pages)/layout.tsx
"use client";
import { useProtectedRoute } from "@/hooks/use-protected-route";
import { Loader } from "lucide-react";
export default function ProtectedLayout({
children,
}: {
children: React.ReactNode;
}) {
const { isLoading } = useProtectedRoute();
if (isLoading) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Loader className="animate-spin" />
</div>
);
}
return <>{children}</>;
}
Let's create both dashboard and profile pages with their respective features:
// src/app/(protected-pages)/dashboard/page.tsx
"use client";
import { useAuthSession } from "@/lib/utils/auth-utils";
import { useSignOut } from "@/lib/utils/auth-utils";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
export default function DashboardPage() {
const { session } = useAuthSession();
const signOut = useSignOut();
return (
<div className="container mx-auto py-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">
Welcome,{" "}
{session?.user.user_metadata.full_name || session?.user.email}
</h1>
<div className="space-x-4">
<Button variant="outline" asChild>
<Link href="/profile">Profile Settings</Link>
</Button>
<Button onClick={signOut} variant="outline">
Sign out
</Button>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Account Status</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Email: {session?.user.email}
</p>
<p className="text-sm text-muted-foreground">
Last Sign In:{" "}
{new Date(
session?.user.last_sign_in_at || ""
).toLocaleDateString()}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button className="w-full" variant="outline" asChild>
<Link href="/profile">Update Profile</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
Now, let's create the profile hook:
// src/hooks/use-profile.ts
import { useState, useEffect } from "react";
import { useToast } from "./use-toast";
import { ProfileFormData } from "@/lib/validations/profile";
import { User } from "@supabase/supabase-js";
export interface Profile {
id: string;
full_name: string | null;
username: string | null;
avatar_url: string | null;
}
interface UseProfile {
profile: Profile | null;
updateProfile: (data: ProfileFormData) => Promise<void>;
isLoading: boolean;
}
export function useProfile(): UseProfile {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const [profile, setProfile] = useState<Profile | null>(null);
useEffect(() => {
async function fetchProfile() {
try {
const response = await fetch("/api/profile");
if (!response.ok) throw new Error("Failed to fetch profile");
const { data } = await response.json();
const user = data as User;
setProfile({
id: user.id,
full_name: user.user_metadata.full_name || null,
username: user.user_metadata.username || null,
avatar_url: user.user_metadata.avatar_url || null,
});
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error ? error.message : "Failed to fetch profile",
});
setProfile(null);
}
}
fetchProfile();
}, [toast]);
const updateProfile = async (data: ProfileFormData) => {
try {
setIsLoading(true);
const response = await fetch("/api/profile", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) throw new Error("Failed to update profile");
toast({
title: "Success",
description: "Profile updated successfully",
});
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description:
error instanceof Error ? error.message : "Failed to update profile",
});
} finally {
setIsLoading(false);
}
};
return { profile, updateProfile, isLoading };
}
// src/app/(protected-pages)/profile/page.tsx
"use client";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import Link from "next/link";
import { useProfile } from "@/hooks/use-profile";
import { ProfileFormData, profileSchema } from "@/lib/validations/profile";
export default function ProfilePage() {
const { profile, updateProfile } = useProfile();
const form = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
full_name: "",
username: "",
avatar_url: "",
},
});
useEffect(() => {
if (profile) {
form.reset({
full_name: profile.full_name || "",
username: profile.username || "",
avatar_url: profile.avatar_url || "",
});
}
}, [profile, form]);
return (
<div className="container mx-auto py-8">
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold">Profile Settings</h1>
<p className="text-muted-foreground">
Manage your account settings and profile information
</p>
</div>
<Form {...form}>
<form
onSubmit={form.handleSubmit(updateProfile)}
className="space-y-6"
>
<FormField
control={form.control}
name="full_name"
render={({ field }) => (
<FormItem>
<FormLabel>Full Name</FormLabel>
<FormControl>
<Input placeholder="John Doe" {...field} />
</FormControl>
<FormDescription>
This is your full name that will be displayed on your
profile
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input placeholder="johndoe" {...field} />
</FormControl>
<FormDescription>
This is your public username
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="avatar_url"
render={({ field }) => (
<FormItem>
<FormLabel>Avatar URL</FormLabel>
<FormControl>
<Input
placeholder="https://example.com/avatar.jpg"
{...field}
/>
</FormControl>
<FormDescription>
Provide a URL for your profile picture
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end space-x-4">
<Button type="button" variant="outline" asChild>
<Link href="/dashboard">Cancel</Link>
</Button>
<Button type="submit">Save Changes</Button>
</div>
</form>
</Form>
</div>
</div>
);
}
These protected pages provide:
- Dashboard with user information and quick actions
- Profile management with form validation
- Custom hook for profile operations
- Type-safe auth operations
- Clean separation of concerns
- Reusable auth utilities
- Proper loading states
- Server-side authentication checks
Looking good! Our components are now serving security with style. But you know what they say - with great UI comes great responsibility. Let's add some serious security measures to make sure our auth system is fortress-level secure! π°
Let's implement crucial security measures to protect our authentication system. We'll cover both basic security measures and optional advanced features.
First, let's implement CSRF protection and security headers:
Update the middleware to include security headers and CSRF protection:
// src/middleware.ts
import { createServerSupabaseClient } from "@/lib/supabase/server";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
const response = NextResponse.next({
request: {
headers: request.headers,
},
});
// Add security headers
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-XSS-Protection", "1; mode=block");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
response.headers.set(
"Permissions-Policy",
"camera=(), microphone=(), geolocation=()"
);
// Add CORS headers for API routes
if (request.nextUrl.pathname.startsWith("/api/")) {
response.headers.set(
"Access-Control-Allow-Origin",
process.env.NEXT_PUBLIC_APP_URL || "*"
);
response.headers.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, OPTIONS"
);
response.headers.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
}
const supabase = await createServerSupabaseClient();
const {
data: { session },
} = await supabase.auth.getSession();
// Protected routes pattern
const isProtectedRoute = request.nextUrl.pathname.startsWith("/profile");
const isAuthRoute = request.nextUrl.pathname.startsWith("/auth");
if (isProtectedRoute && !session) {
return NextResponse.redirect(new URL("/auth/login", request.url));
}
if (isAuthRoute && session) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
return response;
}
export const config = {
matcher: ["/api/:path*", "/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
"But do we really need all these security measures?" I hear you ask. Well, do you need a seatbelt while driving? Same energy! In the wild west of the internet, it's better to be the sheriff than the outlaw. π€
For rate limiting, we have two options:
Option 1: Using Upstash Redis (Recommended for Production)
First, install the required dependencies:
Create your rate limiter utility:
// src/lib/utils/rate-limit.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { Redis } from "@upstash/redis";
// Make sure to add UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN to your .env
const redis = Redis.fromEnv();
interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}
export async function rateLimit(
request: NextRequest,
config: RateLimitConfig = { maxRequests: 5, windowMs: 60000 }
) {
const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const key = `rate-limit:${ip}`;
const current = (await redis.get<number>(key)) || 0;
if (current >= config.maxRequests) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
await redis.setex(key, config.windowMs / 1000, current + 1);
return null;
}
Option 2: In-Memory Rate Limiting (Simple Development Setup)
For development or simple applications:
// src/lib/utils/simple-rate-limit.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const rateLimit = new Map<string, { count: number; resetTime: number }>();
interface RateLimitConfig {
maxRequests: number;
windowMs: number;
}
export function simpleRateLimit(
request: NextRequest,
config: RateLimitConfig = { maxRequests: 5, windowMs: 60000 }
) {
const ip = request.headers.get("x-forwarded-for") ?? "127.0.0.1";
const now = Date.now();
const limit = rateLimit.get(ip);
if (!limit || now > limit.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + config.windowMs });
return null;
}
if (limit.count >= config.maxRequests) {
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}
limit.count++;
return null;
}
Update our auth store to handle session expiration:
// src/lib/stores/use-auth-store.ts
import { User } from '@supabase/supabase-js'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
interface AuthState {
user: User | null
isLoading: boolean
sessionExpiry: number | null
setUser: (user: User | null) => void
setLoading: (isLoading: boolean) => void
setSessionExpiry: (expiry: number | null) => void
clearSession: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
isLoading: true,
sessionExpiry: null,
setUser: (user) => set({ user }),
setLoading: (isLoading) => set({ isLoading }),
setSessionExpiry: (expiry) => set({ sessionExpiry: expiry }),
clearSession: () => set({ user: null, sessionExpiry: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => sessionStorage),
partialize: (state) => ({
user: state.user,
sessionExpiry: state.sessionExpiry,
}),
}
)
)
// src/lib/providers/auth-provider.tsx
"use client";
import { useEffect } from "react";
import { useAuthStore } from "@/lib/stores/use-auth-store";
import { createClientSupabaseClient } from "@/lib/supabase/client";
export function AuthProvider({ children }: { children: React.ReactNode }) {
const { setUser, setSessionExpiry, clearSession } = useAuthStore();
const supabase = createClientSupabaseClient();
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setUser(session.user);
setSessionExpiry(session.expires_at ?? null);
}
setLoading(false);
});
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (session) {
setUser(session.user);
setSessionExpiry(session.expires_at ?? null);
} else {
clearSession();
}
setLoading(false);
});
// Check session expiry periodically
const checkInterval = setInterval(() => {
const expiry = useAuthStore.getState().sessionExpiry;
if (expiry && Date.now() / 1000 >= expiry) {
clearSession();
window.location.href = "/login";
}
}, 60000); // Check every minute
return () => {
subscription.unsubscribe();
clearInterval(checkInterval);
};
}, [setUser, setSessionExpiry, clearSession, setLoading]);
return <>{children}</>;
}
Here's how to use rate limiting in your API routes:
// src/app/api/profile/route.ts
import { withAuth } from "@/lib/api/with-auth"
import { rateLimit } from "@/lib/utils/rate-limit" // or simpleRateLimit
import { NextResponse } from "next/server"
import type { NextRequest } from "next/server"
export async function GET(request: NextRequest) {
// Check rate limit first
const rateLimitResult = await rateLimit(request, {
maxRequests: 5,
windowMs: 60000, // 1 minute
})
if (rateLimitResult) return rateLimitResult
return withAuth(request, async (userId) => {
// Your existing route handler code
})
}
These security implementations provide:
- CSRF protection for API routes
- Configurable rate limiting options
- Secure session management
- Protection against common web vulnerabilities
- Type safety across all security measures
- CORS configuration for API routes
Key security features:
- Security headers for protection against XSS and clickjacking
- Optional rate limiting with multiple implementation options
- Secure session storage and management
- Type safety across all security measures
- CORS configuration for API routes
Environment Variables Required:
# Required for Upstash Redis rate limiting (Option 1)
UPSTASH_REDIS_REST_URL=your_redis_url
UPSTASH_REDIS_REST_TOKEN=your_redis_token
# Required for CORS
NEXT_PUBLIC_APP_URL=your_app_url
Wow, we've built quite the security system here! It's like having a bouncer, a security camera, and a vault all in one. But how do we know it all works as expected? Time to put on our testing hats! π―
Testing might not be as exciting as building features, but it's like having insurance for your code. Sure, it's not flashy, but you'll be thankful for it when things go sideways! Let's make sure our authentication system is bulletproof. π‘οΈ
First, install the required testing dependencies:
pnpm add -D vitest @testing-library/react @testing-library/jest-dom @testing-library/user-event msw @vitejs/plugin-react
Create a vitest.config.ts file in the root of your project:
// vitest.config.ts
import { defineConfig } from "vitest/config";
import path from "path";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
globals: true,
include: ["**/*.test.{ts,tsx}"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
Create handlers for auth-related API calls:
// src/test/mocks/handlers.ts
import { http, HttpResponse } from "msw";
export const handlers = [
http.post("/api/auth/login", async ({ request }) => {
const body = (await request.json()) as any;
if (body.email === "test@example.com" && body.password === "password123") {
return HttpResponse.json({
data: {
user: {
id: "123",
email: "test@example.com",
},
},
});
}
return HttpResponse.json({ error: "Invalid credentials" }, { status: 400 });
}),
http.get("/api/profile", async () => {
return HttpResponse.json({
data: {
id: "123",
user_metadata: {
full_name: "Test User",
username: "testuser",
avatar_url: null,
},
},
});
}),
http.put("/api/profile", () => {
return new Response(
JSON.stringify({ message: "Profile updated successfully" }),
{ status: 200 }
);
}),
];
Create a mock supabase client:
// src/test/mocks/supabase.ts
import { vi } from "vitest";
export const mockSupabaseClient = {
auth: {
signInWithOAuth: vi.fn(),
signInWithPassword: vi.fn(),
signUp: vi.fn(),
signOut: vi.fn(),
getSession: vi.fn(),
},
};
vi.mock("@/lib/supabase/client", () => ({
createClientSupabaseClient: () => mockSupabaseClient,
}));
Create a mock server for testing:
// src/test/mocks/server.ts
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
export const server = setupServer(...handlers);
Create a test setup file:
// src/test/setup.ts
import "@testing-library/jest-dom";
import { afterAll, afterEach, beforeAll, vi } from "vitest";
import { server } from "./mocks/server";
import { mockSupabaseClient } from "./mocks/supabase";
// Mock Supabase client
vi.mock("@/lib/supabase/client", () => ({
createClientSupabaseClient: () => mockSupabaseClient,
}));
// Setup MSW
beforeAll(async () => {
console.log("π’ Starting MSW Server");
try {
await server.listen({
onUnhandledRequest: (req) => {
console.error(
`β Found an unhandled ${req.method} request to ${req.url.toString()}`
);
},
});
console.log("β
MSW Server started successfully");
} catch (error) {
console.error("β Failed to start MSW Server:", error);
throw error;
}
});
afterEach(() => {
console.log("π Resetting MSW handlers");
server.resetHandlers();
vi.clearAllMocks();
});
afterAll(() => {
console.log("π΄ Stopping MSW Server");
server.close();
});
Let's write tests for our auth components:
// src/components/auth/login-form.test.tsx
import { TestWrapper } from "@/test/test-utils";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { LoginForm } from "./login-form";
// Mock next/navigation
vi.mock("next/navigation", () => ({
useRouter: () => ({
push: vi.fn(),
}),
}));
// Mock auth store
vi.mock("@/lib/stores/use-auth-store", () => ({
useAuthStore: () => ({
setUser: vi.fn(),
setLoading: vi.fn(),
isLoading: false,
}),
}));
describe("LoginForm", () => {
it("submits the form with valid data", async () => {
const user = userEvent.setup();
render(<LoginForm />, { wrapper: TestWrapper });
const emailInput = screen.getByLabelText(/email/i);
const passwordInput = screen.getByLabelText(/password/i);
await user.type(emailInput, "test@example.com");
await user.type(passwordInput, "password123");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(
screen.getByText(/you have successfully logged in/i)
).toBeInTheDocument();
});
});
it("shows validation errors for invalid data", async () => {
const user = userEvent.setup();
render(<LoginForm />, { wrapper: TestWrapper });
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(
screen.getByText(/please enter a valid email address/i)
).toBeInTheDocument();
});
});
it("shows error message for invalid credentials", async () => {
const user = userEvent.setup();
render(<LoginForm />, { wrapper: TestWrapper });
await user.type(screen.getByLabelText(/email/i), "wrong@example.com");
await user.type(screen.getByLabelText(/password/i), "wrongpass");
await user.click(screen.getByRole("button", { name: /sign in/i }));
await waitFor(() => {
expect(screen.getByText(/invalid credentials/i)).toBeInTheDocument();
});
});
});
Test our custom hooks:
// src/hooks/use-profile.test.ts
import { renderHook, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { TestWrapper } from "../test/test-utils";
import { useProfile } from "./use-profile";
describe("useProfile", () => {
it("fetches profile data successfully", async () => {
const { result } = renderHook(() => useProfile(), {
wrapper: TestWrapper,
});
expect(result.current.profile).toBeNull();
await waitFor(
() => {
return expect(result.current.profile).not.toBeNull();
},
{
timeout: 2000,
interval: 100,
}
);
expect(result.current.profile).toEqual({
id: "123",
full_name: "Test User",
username: "testuser",
avatar_url: null,
});
});
});
To run your tests, use the following command:
These testing configurations provide:
- Comprehensive test coverage for components and hooks
- Mock service worker for API testing
- Type-safe testing setup
- Proper error handling in tests
Key testing features:
- Component testing with React Testing Library
- Hook testing with proper mocking
- API mocking with MSW
The moment of truth approaches! We've built, tested, and polished our authentication system. Now it's time to share our masterpiece with the world. Buckle up, because we're about to deploy! π
First, make sure you have the Vercel CLI installed (because we're fancy like that):
Now, let's prepare our project for its grand debut:
- Push your code to GitHub (yes, you should have been doing this all along π)
- Log in to Vercel:
- Configure your project (the moment of truth):
When prompted, choose these options (because who doesn't love a good Q&A session?):
? Set up and deploy? Yes
? Which scope? Your Account
? Link to existing project? No
? What's your project name? your-awesome-auth-project
? In which directory is your code located? ./
? Want to override the settings? No
Head over to your Vercel project settings and add these environment variables (because secrets should stay secret):
NEXT_PUBLIC_SUPABASE_URL=your_production_supabase_url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your_production_anon_key
NEXT_PUBLIC_APP_URL=https://your-awesome-auth-project.vercel.app
# If you're using Upstash Redis (fancy!)
UPSTASH_REDIS_REST_URL=your_redis_url
UPSTASH_REDIS_REST_TOKEN=your_redis_token
Before hitting that deploy button, let's run through our pre-flight checklist:
// deployment-checklist.ts
const deploymentChecklist = {
security: [
"β Environment variables are set",
"β Security headers are configured",
"β Rate limiting is in place",
"β CORS is properly configured",
],
performance: [
"β Build output is optimized",
"β Images are optimized",
"β Caching is configured",
],
monitoring: [
"β Error tracking is set up",
"β Analytics are configured",
"β Logging is in place",
],
testing: [
"β All tests pass",
"β Production build succeeds",
"β Links work correctly",
]
}
And voilΓ ! Your authentication system is now live and ready to protect your users' data like Fort Knox (but with better UX).
These deployment steps provide:
- One-click deployment with Vercel
- Environment variable management
- Production-ready configuration
- Automatic SSL certificates (because security is sexy)
- Continuous deployment from your main branch
- Edge network distribution (speed demons unite!)
Key deployment features:
- Zero-config deployment
- Automatic HTTPS
- Environment management
- Preview deployments
- Analytics and monitoring
Remember: With great deployment comes great responsibility. Keep an eye on those logs, and may your error rates be ever in your favor! π―
Congratulations on building a production-ready authentication system! Let's recap what we've accomplished (because who doesn't love a good highlight reel?):
- Implemented secure authentication with Supabase π
- Created type-safe forms with Zod validation β¨
- Built beautiful UI components with Shadcn UI π¨
- Added robust error handling (because users do weird things) π οΈ
- Implemented rate limiting (goodbye, bot armies!) π€
- Set up comprehensive testing (sleep better at night) π΄
- Deployed to Vercel (smooth like butter) π
- Type Safety: TypeScript + Zod = Developer happiness
- Security: Multiple layers of protection (like an onion, but less tearful)
- User Experience: Clean UI with proper feedback
- Maintainability: Custom hooks and clean separation of concerns
- Testing: Comprehensive test coverage for peace of mind
You did it! π You've not just built an authentication system - you've crafted a secure, tested, and production-ready fortress for your users' data. Pat yourself on the back, grab your beverage of choice, and take a moment to appreciate what you've accomplished.
But remember, in the ever-evolving world of web development, this is just the beginning. Keep learning, keep building, and most importantly, keep your dependencies updated (because security vulnerabilities don't take vacations! π
).
- Add Social Logins: Because who remembers passwords anyway?
- Implement MFA: Double the security, double the fun!
- Add Password Recovery: Users forget stuff, help them out
- Custom Email Templates: Make those auth emails fancy
- Star the repo (if you found this helpful)
- Share your implementation on Twitter/X (don't forget to tag us!)
- Submit PRs for improvements (we love collaborators!)
- Follow our blog for more tutorials
- Connect with us on social media
Remember: Authentication is just the beginning. Now go forth and build something amazing! And if you run into bugs... well, that's what Stack Overflow is for, right? π
Happy coding