ทำเว็บระบบ Login แบบ Email/Password ด้วย Better Auth และ Next.js
วันนี้จะมาสร้างเว็บ Next.js ทำระบบ Login แบบใช้ Email/Password โดยใช้ Better Auth และ database เป็น sqlite3 ตัวอย่างมี 3 หน้าคือ
- หน้า Register
- หน้า Login
- หน้า Dashboard (เป็น Protected route ต้อง login ก่อน)
สิ่งที่จะได้เรียนรู้
- สามารถติดตั้ง และ config better-auth กับ Next.js และเลือก database ได้ (ตัวอย่างใช้ sqlite3)
- Protected route ด้วย server component
- sign in/up และ sign out จาก client component
Step 1: สร้างโปรเจ็ค
bun create next-app@latest hello-better-auth --yescd hello-better-authแก้ไขหน้า Page
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
"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
"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'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
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
"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
bun add better-auth better-sqlite3bun add -D @types/better-sqlite3สร้างไฟล์ .env ที่ root ของโปรเจ็ค
BETTER_AUTH_SECRET=your-secret-key-at-least-32-characters-longBETTER_AUTH_URL=http://localhost:3000Generate secret ด้วย
openssl rand -base64 32Step 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) ด้วยคำสั่ง
npx better-auth/cli@latest generatenpx @better-auth/cli@latest migrateหากติดปัญหา sqlite3 ลองทำการ rebuild ใหม่
npm rebuild better-sqlite3
ตรวจสอบว่าไฟล์ sqlite.db ถูกสร้างขึ้นมา และ table user, session, account, verification มีอยู่
Step 7: API Route เพื่อ handle auth
import { auth } from "@/lib/auth";import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth);Step 8: Auth Client
ไฟล์นี้สำหรับเรียกใช้ฝั่ง client ตัวอย่างนี้เราเป็น React
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)- Better Auth Skills - https://github.com/better-auth/skills
- LLMs txt - https://www.better-auth.com/llms.txt
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.tsxexport default function DashboardPage() { return <div>Dashboard</div>;}