NEW

ทำเว็บระบบ Login แบบ Email/Password ด้วย Better Auth และ Next.js

NextJS Basic

วันนี้จะมาสร้างเว็บ Next.js ทำระบบ Login แบบใช้ Email/Password โดยใช้ Better Auth และ database เป็น sqlite3 ตัวอย่างมี 3 หน้าคือ

  1. หน้า Register
  2. หน้า Login
  3. หน้า Dashboard (เป็น Protected route ต้อง login ก่อน)

สิ่งที่จะได้เรียนรู้

  • สามารถติดตั้ง และ config better-auth กับ Next.js และเลือก database ได้ (ตัวอย่างใช้ sqlite3)
  • Protected route ด้วย server component
  • sign in/up และ sign out จาก client component

Step 1: สร้างโปรเจ็ค

Terminal window
bun create next-app@latest hello-better-auth --yes
cd hello-better-auth

แก้ไขหน้า Page

page.tsx
import Image from "next/image";
import Link from "next/link";
const BLOG_POST_URL = "https://www.devahoy.com/videos/build-login-with-better-auth-nextjs/";
const SOURCE_CODE_URL = "https://github.com/devahoy/better-auth-email-password-with-nextjs";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
Better Auth + Next.js demo app.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Minimal email/password authentication flow using Better Auth with
Next.js App Router.
</p>
<p className="max-w-md text-sm leading-7 text-zinc-500 dark:text-zinc-400">
This is a demo from my blog post.{" "}
<a
href={BLOG_POST_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline text-zinc-900 dark:text-zinc-100"
>
Read the post
</a>{" "}
and{" "}
<a
href={SOURCE_CODE_URL}
target="_blank"
rel="noopener noreferrer"
className="font-medium underline text-zinc-900 dark:text-zinc-100"
>
view source code
</a>
.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<Link
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="/login"
>
Login
</Link>
<Link
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="/register"
>
Register
</Link>
</div>
</main>
</div>
);
}

Step 2: สร้างหน้า Register

app/register/page.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function RegisterPage() {
const router = useRouter();
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
// TODO
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 font-sans dark:bg-black">
<main className="w-full max-w-md rounded-2xl border border-black/[.08] bg-white p-8 dark:border-white/[.145] dark:bg-black">
<h1 className="text-2xl font-semibold text-black dark:text-zinc-50">
Register
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Create a new account with email and password.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-1">
<label
htmlFor="name"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Name
</label>
<input
id="name"
name="name"
type="text"
autoComplete="name"
required
className="h-10 w-full rounded-md border border-black/[.08] bg-white px-3 text-sm text-black outline-none ring-black/20 transition placeholder:text-zinc-400 focus:border-black/20 focus:ring-2 dark:border-white/[.145] dark:bg-black dark:text-zinc-100 dark:ring-white/20 dark:placeholder:text-zinc-500 dark:focus:border-white/30"
/>
</div>
<div className="space-y-1">
<label
htmlFor="email"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="h-10 w-full rounded-md border border-black/[.08] bg-white px-3 text-sm text-black outline-none ring-black/20 transition placeholder:text-zinc-400 focus:border-black/20 focus:ring-2 dark:border-white/[.145] dark:bg-black dark:text-zinc-100 dark:ring-white/20 dark:placeholder:text-zinc-500 dark:focus:border-white/30"
/>
</div>
<div className="space-y-1">
<label
htmlFor="password"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="new-password"
required
minLength={8}
className="h-10 w-full rounded-md border border-black/[.08] bg-white px-3 text-sm text-black outline-none ring-black/20 transition placeholder:text-zinc-400 focus:border-black/20 focus:ring-2 dark:border-white/[.145] dark:bg-black dark:text-zinc-100 dark:ring-white/20 dark:placeholder:text-zinc-500 dark:focus:border-white/30"
/>
</div>
{error && (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/70 dark:bg-red-950/40 dark:text-red-300">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center rounded-full bg-foreground px-4 text-sm font-medium text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Creating account..." : "Create account"}
</button>
</form>
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-zinc-950 underline dark:text-zinc-50"
>
Login
</Link>
</p>
<div className="mt-3">
<Link
href="/"
className="text-sm text-zinc-500 underline dark:text-zinc-400"
>
Back to home
</Link>
</div>
</main>
</div>
);
}

Step 3: สร้างหน้า Login

app/login/page.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
// TODO
}
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 font-sans dark:bg-black">
<main className="w-full max-w-md rounded-2xl border border-black/[.08] bg-white p-8 dark:border-white/[.145] dark:bg-black">
<h1 className="text-2xl font-semibold text-black dark:text-zinc-50">
Login
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
Sign in with your email and password.
</p>
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
<div className="space-y-1">
<label
htmlFor="email"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="h-10 w-full rounded-md border border-black/[.08] bg-white px-3 text-sm text-black outline-none ring-black/20 transition placeholder:text-zinc-400 focus:border-black/20 focus:ring-2 dark:border-white/[.145] dark:bg-black dark:text-zinc-100 dark:ring-white/20 dark:placeholder:text-zinc-500 dark:focus:border-white/30"
/>
</div>
<div className="space-y-1">
<label
htmlFor="password"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="h-10 w-full rounded-md border border-black/[.08] bg-white px-3 text-sm text-black outline-none ring-black/20 transition placeholder:text-zinc-400 focus:border-black/20 focus:ring-2 dark:border-white/[.145] dark:bg-black dark:text-zinc-100 dark:ring-white/20 dark:placeholder:text-zinc-500 dark:focus:border-white/30"
/>
</div>
{error && (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800/70 dark:bg-red-950/40 dark:text-red-300">
{error}
</p>
)}
<button
type="submit"
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center rounded-full bg-foreground px-4 text-sm font-medium text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Signing in..." : "Sign in"}
</button>
</form>
<p className="mt-4 text-sm text-zinc-600 dark:text-zinc-400">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-zinc-950 underline dark:text-zinc-50"
>
Register
</Link>
</p>
<div className="mt-3">
<Link
href="/"
className="text-sm text-zinc-500 underline dark:text-zinc-400"
>
Back to home
</Link>
</div>
</main>
</div>
);
}

Step 4: สร้างหน้า Dashboard

app/dashboard/page.tsx
import Link from "next/link";
import SignOutButton from "@/components/sign-out-button";
export default async function DashboardPage() {
const session = {
user: {
name: "mock",
email: "mock@example.com",
},
};
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 px-4 font-sans dark:bg-black">
<main className="w-full max-w-md rounded-2xl border border-black/[.08] bg-white p-8 dark:border-white/[.145] dark:bg-black">
<h1 className="text-2xl font-semibold text-black dark:text-zinc-50">
Dashboard
</h1>
<p className="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
You are signed in.
</p>
<div className="mt-6 space-y-2 rounded-md border border-black/[.08] bg-zinc-50 p-4 dark:border-white/[.145] dark:bg-[#111]">
<p className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium text-zinc-900 dark:text-zinc-100">
Name:
</span>{" "}
{session.user.name}
</p>
<p className="text-sm text-zinc-700 dark:text-zinc-300">
<span className="font-medium text-zinc-900 dark:text-zinc-100">
Email:
</span>{" "}
{session.user.email}
</p>
</div>
<div className="mt-6">
<SignOutButton />
</div>
<div className="mt-3">
<Link
href="/"
className="text-sm text-zinc-500 underline dark:text-zinc-400"
>
Back to home
</Link>
</div>
</main>
</div>
);
}

และ SignOutButton

components/sign-out-button.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SignOutButton() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSignOut() {
// todo
}
return (
<button
type="button"
onClick={handleSignOut}
disabled={isSubmitting}
className="inline-flex h-10 w-full items-center justify-center rounded-full bg-foreground px-4 text-sm font-medium text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] disabled:cursor-not-allowed disabled:opacity-60"
>
{isSubmitting ? "Signing out..." : "Sign Out"}
</button>
);
}

Step 5: ตั้งค่า Better Auth

ติดตั้ง better-auth และ better-sqlite3

Terminal window
bun add better-auth better-sqlite3
bun add -D @types/better-sqlite3

สร้างไฟล์ .env ที่ root ของโปรเจ็ค

BETTER_AUTH_SECRET=your-secret-key-at-least-32-characters-long
BETTER_AUTH_URL=http://localhost:3000

Generate secret ด้วย

Terminal window
openssl rand -base64 32

Step 6: สร้าง Better Auth Instance

ไฟล์ต้องชื่อ auth.ts เซฟไว้ที่ไหนก็ได้ เช่น root project, /lib หรือ utils ตัวอย่างนี้ใช้ /lib

// `lib/auth.ts`
import { betterAuth } from "better-auth";
import Database from "better-sqlite3";
export const auth = betterAuth({
database: new Database("./sqlite.db"),
emailAndPassword: {
enabled: true,
},
});

ทำการ generate database schema และ สร้าง table (migrate) ด้วยคำสั่ง

Terminal window
npx better-auth/cli@latest generate
npx @better-auth/cli@latest migrate

หากติดปัญหา sqlite3 ลองทำการ rebuild ใหม่ npm rebuild better-sqlite3

ตรวจสอบว่าไฟล์ sqlite.db ถูกสร้างขึ้นมา และ table user, session, account, verification มีอยู่

Step 7: API Route เพื่อ handle auth

app/api/auth/[...all]/route.ts
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);

Step 8: Auth Client

ไฟล์นี้สำหรับเรียกใช้ฝั่ง client ตัวอย่างนี้เราเป็น React

lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const { signIn, signOut, signUp, useSession } = createAuthClient();

Step 9 : Implement Register (signUp)

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signUp } from "@/lib/auth-client";
export default function RegisterPage() {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
const formData = new FormData(e.currentTarget);
const name = String(formData.get("name") ?? "").trim();
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const { error } = await signUp.email({
name,
email,
password,
});
if (error) {
setError(error.message ?? "Register failed");
return;
}
router.push("/dashboard");
} catch {
setError("Unexpected error while creating account");
} finally {
setIsSubmitting(false);
}
}
// return (...)
}

Step 10: Login Page

Handle login page

"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { signIn } from "@/lib/auth-client";
export default function LoginPage() {
const router = useRouter();
const [error, setError] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setError("");
setIsSubmitting(true);
try {
const formData = new FormData(e.currentTarget);
const email = String(formData.get("email") ?? "").trim();
const password = String(formData.get("password") ?? "");
const { error } = await signIn.email({
email,
password,
});
if (error) {
setError(error.message ?? "Login failed");
return;
}
router.push("/dashboard");
} catch {
setError("Unexpected error while logging in");
} finally {
setIsSubmitting(false);
}
}
// return (...)
}

Step 11: Dashboard (Protected)

app/dashboard/page.tsx — server component ตรวจ session ฝั่ง server

import Link from "next/link";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import SignOutButton from "@/components/sign-out-button";
import { auth } from "@/lib/auth";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/");
}
// return (...)
}

SignOutButton

async function handleSignOut() {
setIsSubmitting(true);
try {
await signOut({
fetchOptions: {
onSuccess: () => {
router.push("/login");
},
},
});
} finally {
setIsSubmitting(false);
}
}

Optional 1 : LLMs

Prompt

Build minimal login with email / password using better auth and nextjs (sqlite3 as database)

Optional 2: Proxy/Middleware

proxy.ts — ตรวจ session cookie ก่อน render protected route

import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function proxy(request: NextRequest) {
const sessionCookie = getSessionCookie(request);
if (!sessionCookie) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard"],
};

Optional 3: Group protected

เราสามารถแยก group protected ออกเป็น folder รวมถึง public route ได้เช่น

src/app/
(public)/
page.tsx -> /
login/page.tsx -> /login
register/page.tsx -> /register
(protected)/
layout.tsx -> wraps all protected pages
dashboard/page.tsx -> /dashboard
settings/page.tsx -> /settings

ตัวอย่าง getSession ที่ layout

// src/app/(protected)/layout.tsx:
import { ReactNode } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { auth } from "@/lib/auth";
export default async function ProtectedLayout({
children,
}: {
children: ReactNode;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session) {
redirect("/login");
}
return <>{children}</>;
}

หน้า dashboard หรือหน้าอื่นๆ ไม่ต้อง getSession

// src/app/(protected)/dashboard/page.tsx
export default function DashboardPage() {
return <div>Dashboard</div>;
}

References

Installation | Better Auth
Learn how to configure Better Auth in your project.better-auth.com
Installation
Next.js integration | Better Auth
Integrate Better Auth with Next.js.better-auth.com
Next.js integration
Basic Usage | Better Auth
Getting started with Better Authbetter-auth.com
Basic Usage