Next.js Authentication with Better Auth
nextjsbetter-authauthenticationdrizzle

Next.js Authentication with Better Auth

Learn how to implement authentication in Next.js with Better Auth, Neon, and Drizzle ORM.

🔐 Implementing Authentication in Next.js Using Better Auth, Neon, and Drizzle ORM

⏱ïļ Reading Time: ~12 minutes


Introduction

Authentication is one of the most important parts of any modern web application. It controls who can access your platform, protects user data, and enables personalized user experiences.

Imagine you're building a SaaS dashboard where users need to sign in with their Google account or email, access protected pages, view their personalized data, and manage their profile. Getting authentication right is crucial for both security and user experience.

In the Next.js ecosystem, there are many authentication solutions available. However, Better Auth stands out because of its simplicity, flexibility, and native support for modern frameworks like Next.js App Router.

In this guide, we'll walk through how to implement a complete authentication system in Next.js using:

✅ Better Auth - Modern authentication library
✅ Neon PostgreSQL - Serverless database
✅ Drizzle ORM - Type-safe database toolkit
✅ Google OAuth - Social authentication
✅ Email & Password - Traditional login
✅ Server Actions - Secure server-side logic
✅ Protected Routes - Middleware-based security

By the end of this article, you'll have a production-ready authentication setup that you can deploy with confidence. 🚀

Let's get started!


Why Better Auth?

Before we dive in, you might be wondering: "Why Better Auth instead of NextAuth.js or other solutions?"

Here's why Better Auth is an excellent choice for modern Next.js applications:

ðŸ“Ķ Native App Router Support - Built specifically for Next.js 13+ with zero adapters needed
ðŸŽŊ TypeScript-First Design - Complete type safety out of the box
🗄ïļ Built-in Drizzle Integration - Seamless database integration with type inference
⚡ Simpler Configuration - Less boilerplate, more straightforward setup
🔒 Better Session Management - Modern cookie-based sessions with secure defaults
ðŸŠķ Smaller Bundle Size - Lightweight and performant
🔌 Plugin Architecture - Extend functionality as needed

If you're starting a new Next.js project in 2026, Better Auth is the modern choice.


Step 1: Project Setup

Setting Up a Next.js Project

First, let's create a fresh Next.js project with the App Router.

bun create next-app@latest better-auth-app
cd better-auth-app
bun dev

During setup, select these options:

  • ✅ TypeScript: Yes (Recommended for type safety)
  • ✅ App Router: Yes (Required)
  • ✅ Tailwind: Yes (Optional, for styling)
  • ❌ src directory: No (For simpler structure)

Installing Better Auth

Now let's install Better Auth using Bun:

bun add better-auth

ðŸ’Ą Tip: Using npm? Run npm install better-auth instead.

Setting Up Environment Variables

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

# Authentication
BETTER_AUTH_SECRET=your_secret_key_here
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Database
DATABASE_URL=your_neon_database_url

# Google OAuth (Optional)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret

⚠ïļ Important: Never commit .env.local to version control!

Generate a secure secret using:

openssl rand -base64 32

Let's understand what each variable does:

  • BETTER_AUTH_SECRET: Encrypts and signs authentication tokens (keep this secret!)
  • BETTER_AUTH_URL: Your application's URL (changes in production)
  • NEXT_PUBLIC_APP_URL: Public-facing URL for client-side auth (accessible in browser)
  • DATABASE_URL: Connection string for your Neon PostgreSQL database
  • GOOGLE_CLIENT_ID & GOOGLE_CLIENT_SECRET: Credentials for Google OAuth
📄 Project Structure (Click to expand)
your-app/
├── app/
│   ├── api/
│   │   └── auth/
│   │       └── [...all]/
│   │           └── route.ts          # Auth API handler
│   ├── login/
│   │   └── page.tsx                  # Login page
│   ├── signup/
│   │   └── page.tsx                  # Signup page
│   ├── dashboard/
│   │   └── page.tsx                  # Protected dashboard
│   ├── layout.tsx                    # Root layout
│   └── page.tsx                      # Home page
├── components/
│   ├── login-form.tsx                # Login form component
│   ├── signup-form.tsx               # Signup form component
│   ├── logout.tsx                    # Logout button
│   └── ui/                           # ShadCN components
├── db/
│   ├── drizzle.ts                    # DB connection
│   └── schema.ts                     # Database schema
├── lib/
│   ├── auth.ts                       # Better Auth config
│   ├── auth-client.ts                # Client auth instance
│   └── utils.ts                      # Utilities
├── server/
│   └── user.ts                       # Server Actions
├── proxy.ts                          # Route protection (Next.js 16)
├── drizzle.config.ts                 # Drizzle configuration
├── .env.local                        # Environment variables
└── package.json

Step 2: Database Configuration

Now that our Next.js project is set up, we need a database to store user accounts, sessions, and authentication data.

Installing Database Dependencies

We'll use Neon (serverless PostgreSQL) with Drizzle ORM for type-safe database access.

Install the required packages:

bun add drizzle-orm @neondatabase/serverless dotenv
bun add -D drizzle-kit

Why these packages?

  • drizzle-orm: Type-safe ORM with zero runtime overhead
  • @neondatabase/serverless: Neon's serverless PostgreSQL driver
  • drizzle-kit: CLI tool for migrations and schema management
  • dotenv: Loads environment variables

Creating a Neon Database

  1. Go to the Neon Console
  2. Click "New Project"
  3. Neon automatically creates a database named neondb
  4. Copy your connection string (looks like this):
postgres://username:password@ep-cool-name-123456.us-east-2.aws.neon.tech/neondb?sslmode=require
  1. Add it to .env.local as DATABASE_URL

Connecting Drizzle ORM to Neon

Create a file to establish the database connection:

db/drizzle.ts

import { config } from "dotenv";
import { drizzle } from "drizzle-orm/neon-http";

config({ path: ".env.local" }); // Load environment variables

export const db = drizzle(process.env.DATABASE_URL!);

This file creates a Drizzle client that connects to your Neon database using the HTTP driver.

Configuring Drizzle Kit

Create drizzle.config.ts in your root directory:

import { config } from "dotenv";
import { defineConfig } from "drizzle-kit";

config({ path: ".env.local" });

export default defineConfig({
  schema: "./db/schema.ts",
  out: "./migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DATABASE_URL!,
  },
});

What does this do?

  • schema: Points to where your database schema is defined
  • out: Where migration files will be generated
  • dialect: Database type (PostgreSQL for Neon)
  • dbCredentials: How to connect to the database

Generating Authentication Tables

Better Auth provides built-in database schemas that work out of the box.

Run this command to generate the schema:

npx @better-auth/cli generate

This creates tables for:

  • ðŸ‘Ī user - Stores user account information
  • 🔑 session - Manages active user sessions
  • 🔗 account - Handles OAuth providers and credentials
  • ✉ïļ verification - Email verification tokens

Now copy the generated schema into db/schema.ts file

📄 db/schema.ts (Click to expand)
import { relations } from "drizzle-orm";
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";

// User table - stores core user information
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: boolean("email_verified").default(false).notNull(),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
});

// Session table - manages user sessions
export const session = pgTable(
"session",
{
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
},
(table) => [index("session_userId_idx").on(table.userId)],
);

// Account table - stores OAuth and password credentials
export const account = pgTable(
"account",
{
id: text("id").primaryKey(),
accountId: text("account_id").notNull(),
providerId: text("provider_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
accessToken: text("access_token"),
refreshToken: text("refresh_token"),
idToken: text("id_token"),
accessTokenExpiresAt: timestamp("access_token_expires_at"),
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
scope: text("scope"),
password: text("password"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("account_userId_idx").on(table.userId)],
);

// Verification table - handles email verification
export const verification = pgTable(
"verification",
{
id: text("id").primaryKey(),
identifier: text("identifier").notNull(),
value: text("value").notNull(),
expiresAt: timestamp("expires_at").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(table) => [index("verification_identifier_idx").on(table.identifier)],
);

// Relations for type-safe joins
export const userRelations = relations(user, ({ many }) => ({
sessions: many(session),
accounts: many(account),
}));

export const sessionRelations = relations(session, ({ one }) => ({
  user: one(user, {
    fields: [session.userId],
    references: [user.id],
  }),
}));

export const accountRelations = relations(account, ({ one }) => ({
  user: one(user, {
    fields: [account.userId],
    references: [user.id],
  }),
}));

// Export schema for Better Auth
export const schema = {
user,
session,
account,
};

Pushing Schema to Database

Now let's create these tables in your Neon database:

npx drizzle-kit push

This command pushes your schema directly to the database without generating migration files.

✅ At this point, your authentication database is ready!

You can verify by checking the Tables section in your Neon dashboard - you should see user, session, account, and verification tables.


Step 3: Better Auth Setup

Now that our database is ready to store users and sessions, let's tell Better Auth how to connect to it and configure our authentication methods.

Configuring Better Auth

Create the main authentication configuration file: ./lib/auth.ts

📄 lib/auth.ts (Click to expand)
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";

import { db } from "@/db/drizzle";
import { schema } from "@/db/schema";

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: "pg",
    schema,
  }),

emailAndPassword: {
enabled: true,
},

socialProviders: {
google: {
clientId: process.env.GOOGLE_CLIENT_ID as string,
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
},
},

baseURL: process.env.BETTER_AUTH_URL,

plugins: [nextCookies()],
});

Let's break down this configuration:

1. Database Adapter

database: drizzleAdapter(db, {
  provider: "pg",
  schema,
});

This connects Better Auth to our PostgreSQL database through Drizzle. The adapter automatically handles creating users, sessions, and OAuth accounts.

2. Email & Password Login

emailAndPassword: {
  enabled: true,
}

Enables traditional username/password authentication. Better Auth automatically hashes passwords using Argon2id (very secure!).

3. Google OAuth

socialProviders: {
  google: {
    clientId: process.env.GOOGLE_CLIENT_ID as string,
    clientSecret: process.env.GOOGLE_CLIENT_SECRET as string,
  },
}

Allows users to sign in using their Google account. We'll set this up later in the Google OAuth section.

4. Cookies Plugin

plugins: [nextCookies()];

This plugin manages sessions using secure HTTP-only cookies, which are safer than localStorage and work seamlessly with Next.js Server Components.

Why Server Actions? They keep sensitive authentication logic on the server, preventing client-side exposure of credentials and providing a better security model.

Creating the Auth API Route

Next, we need to expose authentication endpoints that the client can call.

Create this catch-all route:

app/api/auth/[...all]/route.ts

import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";

export const { POST, GET } = toNextJsHandler(auth);

What does this do?

This single file automatically handles all authentication requests:

  • POST /api/auth/sign-in/email - Email login
  • POST /api/auth/sign-up/email - Email signup
  • GET /api/auth/session - Get current session
  • POST /api/auth/sign-out - Logout
  • GET /api/auth/callback/google - Google OAuth callback
  • And many more!

The [...all] catch-all route means any path under /api/auth/ will be handled by Better Auth.

Creating the Client Auth Instance

For frontend components to interact with our auth server, we need a client instance.

lib/auth-client.ts

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000",
});

This client provides React hooks and methods for Signing in/up, Checking session status, Logging out, OAuth flows.

ðŸ’Ą Note: We use NEXT_PUBLIC_APP_URL because client components need access to this variable in the browser.

Creating Server Actions for Login & Signup

Server Actions allow us to securely handle authentication from the client while keeping sensitive logic on the server.

Create: server/user.ts

📄 server/user.ts (Click to expand)
"use server";

import { auth } from "@/lib/auth";

export const signIn = async (email: string, password: string) => {
  try {
    await auth.api.signInEmail({
      body: {
        email,
        password,
      },
    });

    return {
      success: true,
      message: "Signed in successfully",
    };

} catch (error) {
const e = error as Error;

    return {
      success: false,
      message: e.message || "An unknown error occurred.",
    };

}
};

export const signUp = async (email: string, password: string, name: string) => {
  try {
    await auth.api.signUpEmail({
      body: {
        email,
        password,
        name,
      },
    });

    return {
      success: true,
      message: "Signed up successfully",
    };

} catch (error) {
const e = error as Error;

    return {
      success: false,
      message: e.message || "An unknown error occurred.",
    };

}
};

Why Server Actions?

  • ✅ Keep sensitive logic server-side
  • ✅ Automatic serialization of data
  • ✅ Type-safe by default
  • ✅ No need to create API routes manually
  • ✅ Progressive enhancement support

These functions run exclusively on the server and securely communicate with Better Auth.


Step 4: Building the UI

Now let's create the user interface for authentication. We'll use ShadCN UI for beautiful, accessible components.

Installing ShadCN UI

First, initialize ShadCN in your project:

bunx --bun shadcn@latest init

Select these options:

  • Style: New York
  • Base color: Slate (or your preference)
  • CSS variables: Yes

Adding Form Components

Install the necessary UI components:

bunx --bun shadcn@latest add button card input label form

ðŸ’Ą Shortcut: ShadCN provides pre-built authentication blocks!

Visit: https://ui.shadcn.com/blocks/login

You can copy the Login and Signup blocks directly and customize them.

Login Page

Create: app/login/page.tsx

📄 app/login/page.tsx (Click to expand)
import { GalleryVerticalEnd } from "lucide-react";
import { LoginForm } from "@/components/login-form";

export default function LoginPage() {
  return (
    <div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
      <div className="flex w-full max-w-sm flex-col gap-6">
        <a href="#" className="flex items-center gap-2 self-center font-medium">
          <div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
            <GalleryVerticalEnd className="size-4" />
          </div>
          Acme Inc.
        </a>
        <LoginForm />
      </div>
    </div>
  );
}

Login Form Component

📄 components/login-form.tsx (Click to expand)
"use client";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { signIn } from "@/server/user";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "./ui/form";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";

const formSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(8, "Password must be at least 8 characters"),
});

export function LoginForm({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
},
});

const signInWithGoogle = async () => {
await authClient.signIn.social({
provider: "google",
callbackURL: "/dashboard",
});
};

async function onSubmit(values: z.infer<typeof formSchema>) {
setIsLoading(true);

    const { success, message } = await signIn(values.email, values.password);

    if (success) {
      toast.success(message);
      router.push("/dashboard");
    } else {
      toast.error(message);
    }

    setIsLoading(false);

}

return (

<div className={cn("flex flex-col gap-6", className)} {...props}>
<Card>
<CardHeader className="text-center">
<CardTitle className="text-xl">Welcome back</CardTitle>
<CardDescription>Login with your Google account</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
<div className="grid gap-6">
{/* Google Sign In */}
<Button
                  className="w-full"
                  onClick={signInWithGoogle}
                  type="button"
                  variant="outline"
                >
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
                      d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
                      fill="currentColor"
                    />
</svg>
Login with Google
</Button>

                <div className="relative text-center text-sm">
                  <span className="bg-background text-muted-foreground px-2">
                    Or continue with
                  </span>
                </div>

                {/* Email Field */}
                <FormField
                  control={form.control}
                  name="email"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Email</FormLabel>
                      <FormControl>
                        <Input placeholder="m@example.com" {...field} />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                {/* Password Field */}
                <FormField
                  control={form.control}
                  name="password"
                  render={({ field }) => (
                    <FormItem>
                      <FormLabel>Password</FormLabel>
                      <FormControl>
                        <Input
                          placeholder="********"
                          {...field}
                          type="password"
                        />
                      </FormControl>
                      <FormMessage />
                    </FormItem>
                  )}
                />

                {/* Submit Button */}
                <Button className="w-full" disabled={isLoading} type="submit">
                  {isLoading ? (
                    <Loader2 className="size-4 animate-spin" />
                  ) : (
                    "Login"
                  )}
                </Button>
              </div>

              <div className="text-center text-sm">
                Don&apos;t have an account?{" "}
                <Link className="underline underline-offset-4" href="/signup">
                  Sign up
                </Link>
              </div>
            </form>
          </Form>
        </CardContent>
      </Card>
    </div>

);
}

Key features of this form:

  • ✅ Zod validation for email and password
  • ✅ Loading states during submission
  • ✅ Toast notifications for feedback
  • ✅ Google OAuth integration
  • ✅ Automatic redirect after success

Signup Page & Form

The signup flow is similar. Create:

app/signup/page.tsx and components/signup-form.tsx

The main difference is calling signUp(email, password, name) instead of signIn().

📄 app/signup/page.tsx (Click to expand)
import { GalleryVerticalEnd } from "lucide-react";

import { SignUpForm } from "@/components/signup-form";

export default function SignupPage() {
  return (
    <div className="bg-muted flex min-h-svh flex-col items-center justify-center gap-6 p-6 md:p-10">
      <div className="flex w-full max-w-sm flex-col gap-6">
        <a href="#" className="flex items-center gap-2 self-center font-medium">
          <div className="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md">
            <GalleryVerticalEnd className="size-4" />
          </div>
          Acme Inc.
        </a>
        <SignUpForm />
      </div>
    </div>
  );
}
📄 components/signup-form.tsx (Click to expand)
"use client";

import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { signUp } from "@/server/user";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "./ui/form";
import Link from "next/link";
import { Loader2 } from "lucide-react";
import { useState } from "react";
import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { authClient } from "@/lib/auth-client";

const formSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  password: z.string().min(8),
});

export function SignUpForm({
  className,
  ...props
}: React.ComponentProps<"div">) {
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const form = useForm<z.infer<typeof formSchema>>({
    resolver: zodResolver(formSchema),
    defaultValues: {
      name: "",
      email: "",
      password: "",
    },
  });

  const signInWithGoogle = async () => {
    await authClient.signIn.social({
      provider: "google",
      callbackURL: "/dashboard",
    });
  };

  async function onSubmit(values: z.infer<typeof formSchema>) {
    setIsLoading(true);

    const { success, message } = await signUp(
      values.email,
      values.password,
      values.name,
    );

    if (success) {
      toast.success(message as string);
      router.push("/dashboard");
    } else {
      toast.error(message as string);
    }
    setIsLoading(false);
  }

  return (
    <div className={cn("flex flex-col gap-6", className)} {...props}>
      <Card>
        <CardHeader className="text-center">
          <CardTitle className="text-xl">Create your account</CardTitle>
          <CardDescription>SignUp with your Google account</CardDescription>
        </CardHeader>
        <CardContent>
          <Form {...form}>
            <form className="space-y-8" onSubmit={form.handleSubmit(onSubmit)}>
              <div className="grid gap-6">
                <div className="flex flex-col gap-4">
                  <Button
                    className="relative w-full"
                    onClick={signInWithGoogle}
                    type="button"
                    variant="outline"
                  >
                    <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
                      <title>Google</title>
                      <path
                        d="M12.48 10.92v3.28h7.84c-.24 1.84-.853 3.187-1.787 4.133-1.147 1.147-2.933 2.4-6.053 2.4-4.827 0-8.6-3.893-8.6-8.72s3.773-8.72 8.6-8.72c2.6 0 4.507 1.027 5.907 2.347l2.307-2.307C18.747 1.44 16.133 0 12.48 0 5.867 0 .307 5.387.307 12s5.56 12 12.173 12c3.573 0 6.267-1.173 8.373-3.36 2.16-2.16 2.84-5.213 2.84-7.667 0-.76-.053-1.467-.173-2.053H12.48z"
                        fill="currentColor"
                      />
                    </svg>
                    SignUp with Google
                    {/* {lastMethod === "google" && (
                      <Badge className="absolute right-2 text-[9px]">
                        last used
                      </Badge>
                    )} */}
                  </Button>
                </div>
                <div className="relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-border after:border-t">
                  <span className="relative z-10 bg-card px-2 text-muted-foreground">
                    Or continue with
                  </span>
                </div>
                <div className="grid gap-6">
                  <div className="grid gap-3">
                    <FormField
                      control={form.control}
                      name="name"
                      render={({ field }) => (
                        <FormItem>
                          <div className="flex items-center justify-between">
                            <FormLabel>name</FormLabel>
                          </div>
                          <FormControl>
                            <Input placeholder="Enter you name" {...field} />
                          </FormControl>
                          <FormMessage />
                        </FormItem>
                      )}
                    />
                  </div>
                  <div className="grid gap-3">
                    <FormField
                      control={form.control}
                      name="email"
                      render={({ field }) => (
                        <FormItem>
                          <div className="flex items-center justify-between">
                            <FormLabel>Email</FormLabel>
                          </div>
                          <FormControl>
                            <Input placeholder="m@example.com" {...field} />
                          </FormControl>
                          <FormMessage />
                        </FormItem>
                      )}
                    />
                  </div>
                  <div className="grid gap-3">
                    <div className="flex flex-col gap-2">
                      <FormField
                        control={form.control}
                        name="password"
                        render={({ field }) => (
                          <FormItem>
                            <FormLabel>Password</FormLabel>
                            <FormControl>
                              <Input
                                placeholder="********"
                                {...field}
                                type="password"
                              />
                            </FormControl>
                            <FormMessage />
                          </FormItem>
                        )}
                      />
                    </div>
                  </div>
                  <Button className="w-full" disabled={isLoading} type="submit">
                    {isLoading ? (
                      <Loader2 className="size-4 animate-spin" />
                    ) : (
                      "Sign up"
                    )}
                  </Button>
                </div>
                <div className="text-center text-sm">
                  Don&apos;t have an account?{" "}
                  <Link className="underline underline-offset-4" href="/signup">
                    Sign up
                  </Link>
                </div>
              </div>
            </form>
          </Form>
        </CardContent>
      </Card>
      <div className="text-balance text-center text-muted-foreground text-xs *:[a]:underline *:[a]:underline-offset-4 *:[a]:hover:text-primary">
        By clicking continue, you agree to our{" "}
        <Link href="#">Terms of Service</Link> and{" "}
        <Link href="#">Privacy Policy</Link>.
      </div>
    </div>
  );
}

Implementing Logout

Create a reusable logout component:

📄 components/logout.tsx (Click to expand)
"use client";

import React from "react";
import { Button } from "./ui/button";
import { LogOut } from "lucide-react";
import { authClient } from "@/lib/auth-client";
import { useRouter } from "next/navigation";
import { toast } from "sonner";

export default function Logout() {
  const router = useRouter();

const handleLogout = async () => {
await authClient.signOut();
toast.success("Logged out successfully!");
router.push("/login");
};

return (

<Button variant="outline" onClick={handleLogout}>
Logout <LogOut className="ml-2 size-4" />
</Button>
);
}

This function clears the session cookie and redirects users to the login page.


Step 5: Route Protection

Now that users can sign in, we need to protect routes like /dashboard so only authenticated users can access them.

Why proxy.ts in Next.js 16?

Starting with Next.js 16, the framework introduced a more explicit naming convention for middleware files. Instead of the generic middleware.ts, authentication-related middleware is now written as proxy.ts to better indicate its purpose and improve code organization.

This change provides:

  • ðŸŽŊ Clearer Intent: The name proxy better describes authentication/authorization checks
  • 🔒 Better Security Patterns: Separates routing middleware from auth middleware
  • ðŸ“Ķ Improved Organization: Makes it easier to distinguish different middleware types

Creating Middleware

📄 ./proxy.ts (Click to expand)
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";

export async function proxy(request: NextRequest) {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

  if (!session) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard"], // Protect dashboard route
};

How it works:

  1. Middleware runs before the request reaches your page
  2. We check if a valid session exists
  3. If no session, redirect to /login
  4. If session exists, allow the request to continue

ðŸ’Ą Tip: You can add multiple routes to the matcher:

matcher: ["/dashboard", "/settings", "/profile"];

Server-Side Session Access

In Server Components, you can access session data directly:

📄 app/dashboard/page.tsx (Click to expand)
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import Logout from "@/components/logout";
import { redirect } from "next/navigation";

export default async function Dashboard() {
  const session = await auth.api.getSession({
    headers: await headers(),
  });

if (!session) {
redirect("/login");
}

return (

<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<div className="text-center">
<h1 className="text-4xl font-bold">Dashboard</h1>
<p className="text-muted-foreground mt-4">
Welcome back, {session.user.name || session.user.email}!
</p>
</div>
<Logout />
</div>
);
}

This provides double protection: both middleware and page-level checks ensure security.


step 6: Google OAuth Setup

Let's configure Google OAuth so users can sign in with their Google account.

1: Create OAuth Credentials

  1. Go to Google Cloud Console
  2. Create a new project or select an existing one
  3. Navigate to APIs & Services → Credentials
  4. Click "Create Credentials" → "OAuth Client ID"
  5. Select "Web application"
  6. Add Authorized redirect URIs:
http://localhost:3000/api/auth/callback/google
https://yourdomain.com/api/auth/callback/google

⚠ïļ Important: The redirect URI must match exactly, including http:// vs https://.

  1. Copy your Client ID and Client Secret
  2. Add them to .env.local:
GOOGLE_CLIENT_ID=your_client_id_here.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your_client_secret_here

2: Test Google Login

  1. Restart your dev server: bun run dev
  2. Go to /login
  3. Click "Login with Google"
  4. You should be redirected to Google's consent screen
  5. After approval, you'll be redirected back to /dashboard

Common Issues:

  • ❌ "Redirect URI mismatch" → Check that your callback URL matches exactly
  • ❌ "Access blocked" → Verify your app is in testing mode and add test users

Step 7: Testing Your Setup

Now let's test your complete authentication system!

Local Testing Checklist

  1. Start the development server:

    bun run dev
  2. Test Email Signup:

    • Visit http://localhost:3000/signup
    • Fill in name, email, and password
    • Submit the form
    • Should redirect to /dashboard
  3. Test Email Login:

    • Visit http://localhost:3000/login
    • Enter your credentials
    • Should redirect to /dashboard
  4. Test Google OAuth:

    • Click "Login with Google"
    • Complete Google sign-in
    • Should redirect to /dashboard
  5. Test Protected Routes:

    • Try visiting /dashboard without logging in
    • Should redirect to /login
  6. Test Logout:

    • Click logout button
    • Should redirect to /login
    • Trying to access /dashboard should redirect back to login

Verify in Database

Check your Neon dashboard to see:

  • New users in the user table
  • Active sessions in the session table
  • OAuth accounts in the account table

Step 7: Production Deployment

Ready to deploy? Here's how to take your auth system to production.

Environment Variables for Production

Update your .env for production:

BETTER_AUTH_SECRET=<generate_new_secret_for_production>
BETTER_AUTH_URL=https://yourdomain.com
NEXT_PUBLIC_APP_URL=https://yourdomain.com

DATABASE_URL=<your_production_neon_url>

GOOGLE_CLIENT_ID=<your_google_client_id>
GOOGLE_CLIENT_SECRET=<your_google_client_secret>

⚠ïļ Security Checklist:

  • ✅ Generate a new BETTER_AUTH_SECRET for production
  • ✅ Use a separate Neon database for production
  • ✅ Never commit .env.local or .env to Git
  • ✅ Use environment variables in your hosting platform

Update Google OAuth Redirect URIs

Add your production callback URL in Google Cloud Console:

https://yourdomain.com/api/auth/callback/google

Related Posts

Common Next.js Mistakes (And How to Avoid Them Like a Pro)

Common Next.js Mistakes (And How to Avoid Them Like a Pro)

A comprehensive guide to avoiding the most common Next.js pitfalls. Learn how to properly use Server Components, Server Actions, caching, and more.

nextjsreactbest-practices+2 more
Read More

“A person who is devoted to learning is dear to me.”

— Shree Krishna, Bhagavad Gita