Next.js Supabase Authentication: Complete Guide - Part 1

Learn how to build secure authentication in Next.js with Supabase, ShadCN UI, and Tailwind CSS. Set up login, signup, and password reset forms.

📅 Published: March 15, 2025 ✏️ Updated: October 29, 2025 By Ojaswi Athghara
#nextjs-auth #supabase #tutorial #web-dev #react #forms

Next.js Supabase Authentication: Complete Guide - Part 1

Why I Chose Supabase Over Traditional Auth Solutions

Building authentication always felt like reinventing the wheel. Every project, the same pattern: user tables, password hashing, email verification, password resets. Then I discovered Supabase, and everything changed.

In this 4-part series, I'll show you exactly how I built a production-ready authentication system using Next.js and Supabase. We're talking email authentication, Google OAuth, row-level security, and automatic profile creation through database triggers. By the end, you'll have a complete, secure authentication system you can deploy confidently.

🔗 Check out the full code for this series here

What Makes This Stack So Powerful

Next.js and Supabase are a perfect match. Next.js gives us server-side rendering, amazing performance optimization, and a developer experience that just works. Supabase provides everything we need on the backend: PostgreSQL database, real-time capabilities, and built-in authentication that actually makes sense.

Here's what we'll cover across this series:

Part 1 (This Post): Setting up our Next.js app with ShadCN UI and creating authentication forms with proper validation

Part 2: Integrating Supabase, configuring routes and middleware, and implementing email authentication

Part 3: Adding Google OAuth authentication for a seamless social login experience

Part 4: Using Prisma ORM for database management, creating automatic triggers, and implementing Row-Level Security

I chose React Hook Forms for form management and Zod for validation because they work beautifully together. Type-safe, performant, and the developer experience is fantastic.

Building Our Foundation with Next.js and ShadCN

Let's start by setting up our project. I'm using pnpm because it's faster and more disk-efficient than npm, but feel free to use your preferred package manager.

Initializing the Project

ShadCN UI combined with Tailwind CSS gives us beautiful, accessible components out of the box. Here's how I set it up:

pnpm dlx shadcn@latest init -d

The -d flag uses default options, which saves us from answering a bunch of prompts. It'll ask you to choose Next.js as your project type. This command sets up everything: Tailwind configuration, CSS variables for theming, and the basic file structure.

Once that's done, spin up the development server:

pnpm dev

Your Next.js app is now running at localhost:3000. Pretty straightforward, right?

Creating Our Authentication Pages

Now comes the fun part—building our authentication forms. We'll need four pages: signup, login, forgot password, and reset password. Each needs to look good, work well, and validate user input properly.

Adding the Required Components

First, let's add the ShadCN components we'll use:

pnpm dlx shadcn@latest add button input form card separator

These components are pre-styled, accessible, and work seamlessly with React Hook Forms. Trust me, this saves hours compared to building from scratch.

Installing Form Dependencies

For form handling and validation, we need a few more packages:

pnpm add zod react-hook-form @hookform/resolvers

And for icons:

pnpm add lucide-react

Lucide React has become my go-to icon library. Clean, consistent, and easy to use.

Setting Up Form Validation with Zod

Before we build the forms, we need validation schemas. Zod makes this incredibly clean and type-safe.

Create a file at lib/constants.ts:

import { z } from "zod";

export const SignupFormSchema = z
    .object({
        email: z.string().email({
            message: "Please enter a valid email address.",
        }),
        password: z.string().min(8, {
            message: "Password must be at least 8 characters.",
        }),
        confirmPassword: z.string(),
    })
    .refine((data) => data.password === data.confirmPassword, {
        path: ["confirmPassword"],
        message: "Passwords do not match",
    });

export const LoginFormSchema = z.object({
    email: z.string().email({
        message: "Please enter a valid email address.",
    }),
    password: z.string().min(1, {
        message: "Password is required.",
    }),
});

export const ForgotPasswordFormSchema = z.object({
    email: z.string().email({
        message: "Please enter a valid email address.",
    }),
});

export const ResetPasswordFormSchema = z
    .object({
        password: z.string().min(8, {
            message: "Password must be at least 8 characters.",
        }),
        confirmPassword: z.string(),
    })
    .refine((data) => data.password === data.confirmPassword, {
        message: "Passwords do not match",
        path: ["confirmPassword"],
    });

export type TSignupFormSchema = z.infer<typeof SignupFormSchema>;
export type TLoginFormSchema = z.infer<typeof LoginFormSchema>;
export type TForgotPasswordFormSchema = z.infer<typeof ForgotPasswordFormSchema>;
export type TResetPasswordFormSchema = z.infer<typeof ResetPasswordFormSchema>;

The beauty of Zod is that we can infer TypeScript types directly from our schemas. Full type safety with zero duplication.

Building the Signup Form

Let's create our signup page at app/signup/page.tsx. This is where users will create their accounts:

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"

import { Mail } from "lucide-react"
import { SignupFormSchema, TSignupFormSchema } from "@/lib/constants"

export default function SignupForm() {
    const form = useForm<TSignupFormSchema>({
        resolver: zodResolver(SignupFormSchema),
        mode: "onChange",
        defaultValues: {
            email: "",
            password: "",
            confirmPassword: "",
        },
    })

    function onSubmit(values: TSignupFormSchema) {
        // We'll implement this in Part 2
        console.log(values)
    }

    return (
        <Card className="w-full max-w-md mx-auto mt-32">
            <CardHeader>
                <CardTitle className="text-2xl">Create an account</CardTitle>
                <CardDescription>Enter your information to get started.</CardDescription>
            </CardHeader>
            <CardContent>
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
                        <FormField
                            control={form.control}
                            name="email"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>Email</FormLabel>
                                    <FormControl>
                                        <Input placeholder="you@example.com" type="email" {...field} />
                                    </FormControl>
                                    <FormDescription>Enter a valid email</FormDescription>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <FormField
                            control={form.control}
                            name="password"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>Password</FormLabel>
                                    <FormControl>
                                        <Input placeholder="Create a password" type="password" {...field} />
                                    </FormControl>
                                    <FormDescription>Must be at least 8 characters</FormDescription>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <FormField
                            control={form.control}
                            name="confirmPassword"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>Confirm password</FormLabel>
                                    <FormControl>
                                        <Input placeholder="Retype the password again" type="password" {...field} />
                                    </FormControl>
                                    <FormDescription>Must be same as the password</FormDescription>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <Button type="submit" className="w-full">
                            Sign up
                        </Button>

                        <div className="relative my-4">
                            <Separator />
                            <div className="absolute inset-0 flex items-center justify-center">
                                <span className="bg-background px-2 text-xs text-muted-foreground">OR CONTINUE WITH</span>
                            </div>
                        </div>

                        <Button type="button" variant="outline" className="w-full" onClick={() => console.log("Google sign-in")}>  
                            <Mail size={16} className="mr-2" />
                            Sign up with Google
                        </Button>
                    </form>
                </Form>
            </CardContent>
            <CardFooter className="flex justify-center border-t pt-6">
                <p className="text-sm text-muted-foreground">
                    Already have an account?{" "}
                    <a href="/login" className="text-primary font-medium hover:underline">
                        Sign in
                    </a>
                </p>
            </CardFooter>
        </Card>
    )
}

Building the Login Form

The login form is simpler since we only need email and password. Create app/login/page.tsx:

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"

import { Mail } from "lucide-react"
import { TLoginFormSchema, LoginFormSchema } from "@/lib/constants"

export default function LoginForm() {
    const form = useForm<TLoginFormSchema>({
        resolver: zodResolver(LoginFormSchema),
        mode: "onChange",
        defaultValues: {
            email: "",
            password: "",
        },
    })

    function onSubmit(values: TLoginFormSchema) {
        // We'll implement this in Part 2
        console.log(values)
    }

    return (
        <Card className="w-full max-w-md mx-auto mt-32">
            <CardHeader>
                <CardTitle className="text-2xl">Welcome back</CardTitle>
                <CardDescription>Sign in to your account to continue</CardDescription>
            </CardHeader>
            <CardContent>
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
                        <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 placeholder="Enter your password" type="password" {...field} />
                                    </FormControl>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <div className="flex items-center justify-end">
                            <Button variant="link" className="px-0 font-normal" type="button">
                                <a href="/forgot-password" className="text-primary font-medium hover:underline">Forgot password?</a>
                            </Button>
                        </div>
                        <Button type="submit" className="w-full">
                            Sign in
                        </Button>

                        <div className="relative my-4">
                            <Separator />
                            <div className="absolute inset-0 flex items-center justify-center">
                                <span className="bg-background px-2 text-xs text-muted-foreground">OR CONTINUE WITH</span>
                            </div>
                        </div>

                        <Button type="button" variant="outline" className="w-full" onClick={() => console.log("Google sign-in")}>
                            <Mail size={16} className="mr-2" />
                            Sign in with Google
                        </Button>
                    </form>
                </Form>
            </CardContent>
            <CardFooter className="flex justify-center border-t pt-6">
                <p className="text-sm text-muted-foreground">
                    Don't have an account?{" "}
                    <a href="/signup" className="text-primary font-medium hover:underline">
                        Sign up
                    </a>
                </p>
            </CardFooter>
        </Card>
    )
}

Creating Forgot Password and Reset Password Forms

Users forget passwords. It happens. Let's create a smooth recovery flow.

Create app/forgot-password/page.tsx:

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"

import { TForgotPasswordFormSchema, ForgotPasswordFormSchema } from "@/lib/constants"

export default function ForgotPasswordForm() {
    const form = useForm<TForgotPasswordFormSchema>({
        resolver: zodResolver(ForgotPasswordFormSchema),
        mode: "onChange",
        defaultValues: {
            email: "",
        },
    })

    function onSubmit(values: TForgotPasswordFormSchema) {
        // We'll implement this in Part 2
        console.log(values)
    }

    return (
        <Card className="w-full max-w-md mx-auto mt-32">
            <CardHeader>
                <CardTitle className="text-2xl">Forgot password</CardTitle>
                <CardDescription>Enter your email to get a verification link</CardDescription>
            </CardHeader>
            <CardContent>
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
                        <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">
                            Send Email
                        </Button>
                    </form>
                </Form>
            </CardContent>
            <CardFooter className="flex justify-center border-t pt-6">
                <p className="text-sm text-muted-foreground">
                    Remember your password?{" "}
                    <a href="/login" className="text-primary font-medium hover:underline">
                        Sign in
                    </a>
                </p>
            </CardFooter>
        </Card>
    )
}

And create app/reset-password/page.tsx:

"use client"

import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"

import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
import { Input } from "@/components/ui/input"

import { TResetPasswordFormSchema, ResetPasswordFormSchema } from "@/lib/constants"

export default function ResetPasswordForm() {
    const form = useForm<TResetPasswordFormSchema>({
        resolver: zodResolver(ResetPasswordFormSchema),
        mode: "onChange",
        defaultValues: {
            password: "",
            confirmPassword: "",
        },
    })

    function onSubmit(values: TResetPasswordFormSchema) {
        // We'll implement this in Part 2
        console.log(values)
    }

    return (
        <Card className="w-full max-w-md mx-auto mt-32">
            <CardHeader>
                <CardTitle className="text-2xl">Reset password</CardTitle>
                <CardDescription>Create a new password</CardDescription>
            </CardHeader>
            <CardContent>
                <Form {...form}>
                    <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
                        <FormField
                            control={form.control}
                            name="password"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>New password</FormLabel>
                                    <FormControl>
                                        <Input placeholder="Create a password" type="password" {...field} />
                                    </FormControl>
                                    <FormDescription>Must be at least 8 characters</FormDescription>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <FormField
                            control={form.control}
                            name="confirmPassword"
                            render={({ field }) => (
                                <FormItem>
                                    <FormLabel>Confirm password</FormLabel>
                                    <FormControl>
                                        <Input placeholder="Retype the password again" type="password" {...field} />
                                    </FormControl>
                                    <FormDescription>Must be same as the password</FormDescription>
                                    <FormMessage />
                                </FormItem>
                            )}
                        />
                        <Button type="submit" className="w-full">
                            Save
                        </Button>
                    </form>
                </Form>
            </CardContent>
        </Card>
    )
}

Understanding the Magic Behind the Forms

Let me explain what makes this setup so powerful:

Type Safety Throughout

The z.infer utility extracts TypeScript types from our Zod schemas. This means our form types are always in sync with our validation rules. Change the schema, and TypeScript will immediately tell you if your components need updating.

Real-Time Validation

Setting mode: "onChange" means validation happens as users type. They get immediate feedback, which dramatically improves the user experience. No more submitting a form only to discover you made a typo.

The Zod Resolver Magic

The zodResolver adapter connects React Hook Form to Zod. It automatically handles validation errors and surfaces them through the form's error state. The render function in each FormField receives these errors via the field prop, which contains all the necessary bindings and error messages.

Testing Your Forms

Before we connect these to Supabase in Part 2, let's test them:

  1. Visit http://localhost:3000/signup
  2. Try submitting with an empty form
  3. Enter an invalid email
  4. Use a password shorter than 8 characters
  5. Make the passwords not match

You should see validation errors appearing in real-time. Try the same with /login, /forgot-password, and /reset-password.

What's Next

We've built a solid foundation with beautiful, validated forms. But right now, they only log to the console. In Part 2, we'll:

  • Create a Supabase project and get API keys
  • Set up server and client Supabase clients
  • Create middleware to protect routes
  • Implement email authentication with server actions
  • Make all these forms actually work!

The authentication flow we've designed here will serve us well throughout the series. Clean separation of concerns, type safety, and great user experience.

Test your forms thoroughly before moving on. Make sure validation works as expected. In the next part, we'll bring them to life with Supabase authentication.

🔗 Check out the full code for this series here


Building authentication doesn't have to be complicated. If you found this guide helpful, share it with other developers learning Next.js and Supabase. Connect with me on Twitter or LinkedIn for more web development tips.

Support My Work

If this guide helped you with this topic, I'd really appreciate your support! Creating comprehensive, free content like this takes significant time and effort. Your support helps me continue sharing knowledge and creating more helpful resources for developers.

☕ Buy me a coffee - Every contribution, big or small, means the world to me and keeps me motivated to create more content!


Cover image by Georg Bommeli on Unsplash

Related Blogs

Ojaswi Athghara

SDE, 4+ Years

© ojaswiat.com 2025-2027