Bangun Aplikasi Fullstack Modern dengan Next.js 15, Shadcn UI, Supabase & Better-Auth
🧩 Pendahuluan: Saatnya Bikin Aplikasi Fullstack yang Gak Ribet, tapi Powerful Pernah gak sih kamu pengen bikin aplikasi web yang keliatannya “niat banget”, tapi pas ngoding malah overthinking duluan karena bingung harus mulai dari mana? Tenang, kamu gak sendiri. Dulu juga gitu—niatnya cuma pengen bikin to-do list yang bisa login, simpan data, dan tampil keren. Tapi begitu nyentuh setup backend, terus UI-nya gak seragam, terus autentikasi juga ribet... akhirnya nyerah di tengah jalan. 😅 Nah, di sinilah serunya teknologi modern sekarang. Kita bisa bangun aplikasi fullstack tanpa harus mikirin terlalu banyak hal ribet. Cukup pake Next.js 15, terus tinggal kombinasikan dengan Shadcn UI, Supabase, dan Better-Auth, semuanya langsung klik kayak potongan puzzle yang pas. 🚀 Next.js 15: Bukan Sekadar Framework Next.js udah lama jadi sahabat para developer React. Tapi di versi 15 ini, dia makin “dewasa”. Yang paling kerasa itu sih App Router yang super modular, lalu ada Server Actions yang bikin kita bisa kirim data langsung dari form ke server—tanpa harus bikin API route sendiri. 😍 Gak cuma itu, Next.js 15 juga makin cakep dengan fitur Streaming, jadi loading halaman bisa lebih cepat dan interaktif. Cocok banget buat aplikasi yang butuh pengalaman pengguna yang mulus. 🎨 Shadcn UI: Desain Keren Gak Harus Ribet Biasanya, bikin UI yang konsisten itu PR banget. Tapi Shadcn UI hadir sebagai “penyelamat” buat kita yang pengen desain bagus tapi gak mau ribet. Dia pake Tailwind di belakang layar, tapi kasih kita komponen siap pakai yang udah konsisten dan bisa dikustomisasi dengan mudah. Enaknya lagi, kita punya kontrol penuh karena komponen-komponennya bisa kita edit sesuka hati. 🛢️ Supabase: Backend Instant, Tanpa Drama Kalau dulu backend berarti harus setup database, bikin API, auth, dsb, sekarang ada Supabase yang udah siap pakai. Kita bisa bikin tabel lewat dashboard, dapet API otomatis, ada fitur realtime juga, bahkan ada autentikasi. Udah kayak Firebase tapi rasa SQL. 🔐 Better-Auth: Autentikasi yang Beneran Better Nah, urusan login biasanya paling nyebelin. Tapi dengan Better-Auth, semua jadi lebih simpel. Dia dibangun khusus untuk Next.js, udah support login dengan email, Google, dsb. Gak perlu setup ribet, dan yang paling penting: aman dan fleksibel. Jadi, bayangin kalau semua komponen ini digabung: framework modern, UI siap pakai, backend instan, dan autentikasi simpel. Kita bisa bikin aplikasi fullstack yang gak cuma works, tapi juga scalable, maintainable, dan tampil profesional. Yuk, kita lanjut ke tahap persiapan proyek. 🔧 ⚙️ Persiapan Proyek: Bangun Pondasi Aplikasi Modern Kita Oke, jadi kamu udah siap bikin aplikasi fullstack kece dengan Next.js 15, Shadcn UI, Supabase, dan Better-Auth. Sekarang saatnya kita mulai dari nol—beneran nol. Kayak bangun rumah, kita mulai dulu dari pondasinya. Di artikel ini, kita akan menggunakan Bun sebagai package manager. Tapi tenang, kalau kamu lebih nyaman pakai npm atau yarn, tetap bisa kok—semua perintahnya tinggal disesuaikan. 🏗️ 1. Bikin Proyek Baru Pakai Next.js 15 Pertama, kita butuh proyek Next.js yang sudah menggunakan App Router (yang kekinian banget). Di sini, kita akan pakai versi 15.3.3. Jalankan perintah berikut di terminal: bunx [email protected] bwa-auth Nanti akan ditanya beberapa hal. Jawaban yang disarankan: ✔ Would you like to use TypeScript? › Yes ✔ Would you like to use ESLint? › Yes ✔ Would you like to use Tailwind CSS? › Yes (Opsional, tapi bikin form lebih cakep) ✔ Would you like to use `src/` directory? › Yes ✔ Would you like to use App Router? › Yes ✔ Would you like to use Turbopack for `next dev`? › Yes ✔ Would you like to customize the default import alias (@/*)? › No Setlah selesai masuk ke folder proyek: cd bwa-auth Lalu jalankan server: bun run dev Kalau semua berjalan lancar, kamu bisa buka browser dan akses http://localhost:3000 untuk melihat project Next.js kamu tampil dengan halaman default. Tampilan awal Next.js 15 🎨 2. Konfigurasi Tailwind CSS (Udah Auto, Tapi Cek Dulu) Buat mastiin aja nih kalo proyek kita udah pakai Tailwind CSS atau belum, di sini kita pakai tailwind v4. Buka file src/app/globals.css dan cek ada kode ini atau tidak: @import "tailwindcss"; Kalau semuanya oke, kita lanjut. 🧩 3. Install Shadcn UI: UI Keren dalam Sekejap Sekarang, kita install Shadcn UI biar komponen-komponennya bisa langsung dipakai. bunx [email protected] init Jika ada pilihan warna, piilih sesuai keinginan kalian atau bisa ikuti ini: √ Which color would you like to use as the base color? » Neutral Selanjutnya kita tambahkan button, input, dan form dengan perintah berikut: bunx [email protected] add button input form sonner Maka struktur folder akan seperti berikut ini: Struktur folder Buka file globals.css lalu ubah jadi seperti berikut ini: @import "tailwindcss"; @import "tw-animate-css"; @custom-variant dark (&:is(.dark *)); @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); --color-sidebar-ring: var(--sidebar-ring); --color-sidebar-border: var(--sidebar-border); --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-accent: var(--sidebar-accent); --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); --color-sidebar-primary: var(--sidebar-primary); --color-sidebar-foreground: var(--sidebar-foreground); --color-sidebar: var(--sidebar); --color-chart-5: var(--chart-5); --color-chart-4: var(--chart-4); --color-chart-3: var(--chart-3); --color-chart-2: var(--chart-2); --color-chart-1: var(--chart-1); --color-ring: var(--ring); --color-input: var(--input); --color-border: var(--border); --color-destructive: var(--destructive); --color-accent-foreground: var(--accent-foreground); --color-accent: var(--accent); --color-muted-foreground: var(--muted-foreground); --color-muted: var(--muted); --color-secondary-foreground: var(--secondary-foreground); --color-secondary: var(--secondary); --color-primary-foreground: var(--primary-foreground); --color-primary: var(--primary); --color-popover-foreground: var(--popover-foreground); --color-popover: var(--popover); --color-card-foreground: var(--card-foreground); --color-card: var(--card); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } :root { --radius: 0.65rem; --background: oklch(1 0 0); --foreground: oklch(0.141 0.005 285.823); --card: oklch(1 0 0); --card-foreground: oklch(0.141 0.005 285.823); --popover: oklch(1 0 0); --popover-foreground: oklch(0.141 0.005 285.823); --primary: oklch(0.623 0.214 259.815); --primary-foreground: oklch(0.97 0.014 254.604); --secondary: oklch(0.967 0.001 286.375); --secondary-foreground: oklch(0.21 0.006 285.885); --muted: oklch(0.967 0.001 286.375); --muted-foreground: oklch(0.552 0.016 285.938); --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); --border: oklch(0.92 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.623 0.214 259.815); --chart-1: oklch(0.646 0.222 41.116); --chart-2: oklch(0.6 0.118 184.704); --chart-3: oklch(0.398 0.07 227.392); --chart-4: oklch(0.828 0.189 84.429); --chart-5: oklch(0.769 0.188 70.08); --sidebar: oklch(0.985 0 0); --sidebar-foreground: oklch(0.141 0.005 285.823); --sidebar-primary: oklch(0.623 0.214 259.815); --sidebar-primary-foreground: oklch(0.97 0.014 254.604); --sidebar-accent: oklch(0.967 0.001 286.375); --sidebar-accent-foreground: oklch(0.21 0.006 285.885); --sidebar-border: oklch(0.92 0.004 286.32); --sidebar-ring: oklch(0.623 0.214 259.815); } .dark { --background: oklch(0.141 0.005 285.823); --foreground: oklch(0.985 0 0); --card: oklch(0.21 0.006 285.885); --card-foreground: oklch(0.985 0 0); --popover: oklch(0.21 0.006 285.885); --popover-foreground: oklch(0.985 0 0); --primary: oklch(0.546 0.245 262.881); --primary-foreground: oklch(0.379 0.146 265.522); --secondary: oklch(0.274 0.006 286.033); --secondary-foreground: oklch(0.985 0 0); --muted: oklch(0.274 0.006 286.033); --muted-foreground: oklch(0.705 0.015 286.067); --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.704 0.191 22.216); --border: oklch(1 0 0 / 10%); --input: oklch(1 0 0 / 15%); --ring: oklch(0.488 0.243 264.376); --chart-1: oklch(0.488 0.243 264.376); --chart-2: oklch(0.696 0.17 162.48); --chart-3: oklch(0.769 0.188 70.08); --chart-4: oklch(0.627 0.265 303.9); --chart-5: oklch(0.645 0.246 16.439); --sidebar: oklch(0.21 0.006 285.885); --sidebar-foreground: oklch(0.985 0 0); --sidebar-primary: oklch(0.546 0.245 262.881); --sidebar-primary-foreground: oklch(0.379 0.146 265.522); --sidebar-accent: oklch(0.274 0.006 286.033); --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.488 0.243 264.376); } @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } Sekarang buka file page.tsx dan ganti jadi sepertit ini: import { Button } from "@/components/ui/button"; export default function Home() { return ( <div className="flex flex-col gap-2 min-h-screen items-center justify-center"> <h1 className="text-4xl font-bold">Hello, BuildWithAngga!</h1> <Button>Get Started!</Button> </div> ); } Dan hasilnya akan seperti berikut: Setup shadcn di Next.js Sekarang kita sudah selesai setup shadcn di proyek Next.js. 🔌 4. Setup Supabase: Siapkan Dapur Datanya 🍳 Sekarang kita udah punya UI yang cantik dan fondasi yang kuat. Tapi belum lengkap tanpa tempat nyimpen data, kan? Nah, di sinilah Supabase masuk sebagai dapur utama penyimpanan dan autentikasi aplikasi kita. Kita bakal: Setup database dan tabel user.Ambil API key dan URL Supabase.Integrasi Supabase + Drizzle ORM biar kerjaan backend makin elegan. 🧱 1. Setup Database lewat Supabase Dashboard Supabase Dashboard Pertama, buka https://supabase.com dan login. Setelah itu: Klik "New Project".Isi:Project Name: bwa-auhtDatabase Password: isi dan simpan baik-baikRegion: pilih yang paling dekat dengan target user (biasanya Singapore untuk Indonesia)Jangan lupa untuk copy database password Supabase: buat proyek baru Sekarang buat file .env di root direktori proyek dan tambhakan kode berikut: # Connect to Supabase via connection pooling with Supavisor. DATABASE_URL="postgres://postgres.[DB_PROJECT_ID]:[DB_PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:6543/postgres?pgbouncer=true" # Direct connection to the database. Used for migrations. DIRECT_URL="postgres://postgres.[DB_PROJECT_ID]:[DB_PASSWORD]@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres" Maka akan seperti ini: Struktur folder Ganti DB_PROJECT_ID dan DB_PASSWORD sesuai degan proyek kalian. Penjelasan: Supabase menyediakan dua jenis koneksi database untuk kebutuhan yang berbeda: DATABASE_URL → Connection Pooling via Supavisor (port 6543) 🔧 Dipakai untuk: Akses database dari aplikasi (runtime) seperti:API routesServer ActionsCron jobsKoneksi jangka panjang yang lebih efisien (connection pooling)Skalabilitas & performa yang lebih baik ⚡ Kenapa? Koneksi ini menggunakan PgBouncer, yaitu sistem connection pooler yang menghemat jumlah koneksi langsung ke database. Cocok banget buat aplikasi production, karena: Supabase punya batas koneksi langsung (maks 20–100) tergantung plan.PgBouncer bisa multiplex banyak koneksi ringan dari app jadi hanya beberapa koneksi nyata ke database. DIRECT_URL → Direct PostgreSQL Connection (port 5432) 🛠️ Dipakai untuk: Database migration tools seperti:drizzle-kit pushprisma migrateraw SQL CLIAkses langsung yang butuh fitur-fitur database yang tidak didukung PgBouncer (misalnya: prepared statements, session-based commands) ⚠️ Kenapa tidak dipakai terus? Direct connection tidak pakai pooling, jadi lebih berat untuk production.Kalau tiap server instance buka koneksi langsung, bisa cepat over-limit dan menyebabkan error (terutama di plan gratis atau kecil). 🔧 2. Setup Drizzle: ORM Rasa Modern, Simple, dan Type-Safe Setelah kita beres konfigurasi Supabase, sekarang saatnya ngenalin "juru masak" buat urusan database kita — namanya Drizzle ORM. Kenapa pakai Drizzle? Karena dia ringan, simple, dan auto type-safe langsung dari skema. Cocok banget buat Next.js 15 yang modern, dan gak bikin pusing kepala kayak ORM-ORM berat lainnya. 🛠️ 1.Install Drizzle dan Postgres Jalankan perintah ini buat install semua kebutuhan: bun add [email protected] [email protected] [email protected]; bun add -D [email protected] Penjelasan cepat: drizzle-orm: core ORM-nyapostgres: client buat koneksi ke database PostgreSQLdrizzle-kit: CLI buat generate migration & types 📁 2.Setup File Konfigurasi Drizzle Buat file bernama drizzle.config.ts di root project: import { defineConfig } from "drizzle-kit"; export default defineConfig({ schema: "./src/db/schema/index.ts", out: "./drizzle", dialect: "postgresql", schemaFilter: ["public", process.env.DB_SCHEMA_NAME!], dbCredentials: { url: process.env.DIRECT_URL!, }, // Print all statements verbose: true, // Always ask for confirmation strict: true, }); 🧩 3.Definisikan Schema Tabel Pada schema ini kita akan pisahkan dalam beberapa file. Sekarang buat file src/db/schema/index.ts: // src/db/schema/index.ts export * from "./schema"; export * from "./user"; export * from "./account"; export * from "./session"; export * from "./verification"; Buat file src/db/schema/schema.ts: // src/db/schema/schema.ts import { pgSchema } from "drizzle-orm/pg-core"; export const dbSchema = pgSchema(process.env.DB_SCHEMA_NAME!); Buat file src/db/schema/user.ts: import { boolean, text, timestamp } from "drizzle-orm/pg-core"; import { dbSchema } from "."; import { z } from "zod"; export const user = dbSchema.table("user", { id: text("id").primaryKey(), name: text("name").notNull(), username: text("username").unique(), displayUsername: text("display_username"), email: text("email").notNull().unique(), emailVerified: boolean("emailVerified").notNull(), image: text("image"), createdAt: timestamp("createdAt").defaultNow(), updatedAt: timestamp("updatedAt") .defaultNow() .$onUpdate(() => new Date()), }); export type UserType = typeof user.$inferSelect; export const signInSchema = z.object({ username: z.string().min(4, { message: "Username is required" }), password: z .string() .min(6, { message: "Password lenght at least 6 characters" }), }); export type SignInValues = z.infer<typeof signInSchema>; Buat file src/db/schema/account.ts: // src/db/schema/account.ts import { text, timestamp } from "drizzle-orm/pg-core"; import { dbSchema, user } from "."; export const account = dbSchema.table("account", { id: text("id").primaryKey(), accountId: text("accountId").notNull(), providerId: text("providerId").notNull(), userId: text("userId") .notNull() .references(() => user.id), accessToken: text("accessToken"), refreshToken: text("refreshToken"), idToken: text("idToken"), accessTokenExpiresAt: timestamp("accessTokenExpiresAt"), refreshTokenExpiresAt: timestamp("refreshTokenExpiresAt"), scope: text("scope"), password: text("password"), createdAt: timestamp("createdAt").defaultNow(), updatedAt: timestamp("updatedAt") .defaultNow() .$onUpdate(() => new Date()), }); Buat file src/db/schema/session.ts: // src/db/schema/session.ts import { text, timestamp } from "drizzle-orm/pg-core"; import { dbSchema, user } from "."; export const session = dbSchema.table("session", { id: text("id").primaryKey(), expiresAt: timestamp("expiresAt").notNull(), token: text("token").notNull().unique(), createdAt: timestamp("createdAt").defaultNow(), updatedAt: timestamp("updatedAt") .defaultNow() .$onUpdate(() => new Date()), ipAddress: text("ipAddress"), userAgent: text("userAgent"), userId: text("userId") .notNull() .references(() => user.id), }); Buat file src/db/schema/verification.ts: // src/db/schema/verification.ts import { text, timestamp } from "drizzle-orm/pg-core"; import { dbSchema } from "./schema"; export const verification = dbSchema.table("verification", { id: text("id").primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expiresAt").notNull(), createdAt: timestamp("createdAt").defaultNow(), updatedAt: timestamp("updatedAt") .defaultNow() .$onUpdate(() => new Date()), }); Buat file src/db/index.ts: // src/db/index.ts import * as schema from "./schema"; import postgres from "postgres"; import { drizzle } from "drizzle-orm/postgres-js"; const client = postgres( process.env.NODE_ENV === "production" ? process.env.DATABASE_URL! // Use DATABASE_URL in production for pooling : process.env.DIRECT_URL!, // Use DIRECT_URL in development for direct access ); export const db = drizzle(client, { schema }); Maka struktur proyek aakan seperti ini: Drizzle struktur Sekarang tambahkan baris kode ini di file .env : DB_SCHEMA_NAME="bwa_auth" Buka file package.json lalu tambahkan custom script seperti berikut ini: "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio --port 5555 --verbose" Sehingga akan menjadi seperti ini: { "name": "bwa-auth", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "next lint", "db:push": "drizzle-kit push", "db:studio": "drizzle-kit studio --port 5555 --verbose" }, "dependencies": {...}, "devDependencies": {...} } Sekarang jalan perintah ini: bun run db:push Maka akan seperti ini: Drizzle push schema 🔐 5. Tambahkan Better-Auth: Login Gak Pake Drama Better-Auth bikin login di Next.js jadi jauh lebih simpel dan modern. Install dulu dependensinya: bun add [email protected] Sekarang buat file src/lib/auth.ts: // src/lib/auth.ts import { db } from "@/db"; import { betterAuth } from "better-auth"; import { username } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", }), plugins: [username()], emailAndPassword: { enabled: true, }, }); Kemudian buat file src/lib/auth-client.ts: // src/lib/auth-client.ts import { createAuthClient } from "better-auth/react"; import { usernameClient } from "better-auth/client/plugins"; export const { signIn, signUp, signOut, useSession, getSession } = createAuthClient({ plugins: [usernameClient()], baseURL: process.env.NEXT_PUBLIC_BASE_URL!, }); Tambahkan kode ini di file .env: # Better Auth BETTER_AUTH_SECRET="better_secret" NEXT_PUBLIC_BASE_URL="<http://localhost:3000>" Kalian bisa generate BETTER_AUTH_SECRET di web resmi better-auth. Sekarang buat API untuk better-auth, caranya buat file src/app/api/auth/[...all]/route.ts: // src/app/api/auth/[...all]/route.ts import { auth } from "@/lib/auth"; import { toNextJsHandler } from "better-auth/next-js"; export const { POST, GET } = toNextJsHandler(auth); Samapi sini sebenerya udah bisa dipake ya, tapi biar makin lengkap kita tambahkan middleware. Middleware di Next.js itu semacam "penjaga gerbang" yang jalan sebelum request masuk ke route atau API handler. Jadi, kita bisa pakai middleware buat: Cek apakah user udah login (autentikasi)Redirect user kalau belum punya aksesTambahin headers, logging, atau trackingAtur locale, timezone, dll Setup Middleware Buat file middleware.ts di root proyek dan tambahkan kode ini: // src/middleware.ts import { NextResponse, type NextRequest } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; import { apiAuthPrefix, authRoutes, DEFAULT_LOGIN_REDIRECT, publicRoutes, } from "./routes"; export async function middleware(request: NextRequest) { const session = getSessionCookie(request); const isApiAuth = request.nextUrl.pathname.startsWith(apiAuthPrefix); const isPublicRoute = publicRoutes.includes(request.nextUrl.pathname); const isAuthRoute = () => { return authRoutes.some((path) => request.nextUrl.pathname.startsWith(path)); }; if (isApiAuth) { return NextResponse.next(); } if (isAuthRoute()) { if (session) { return NextResponse.redirect( new URL(DEFAULT_LOGIN_REDIRECT, request.url) ); } return NextResponse.next(); } if (!session && !isPublicRoute) { return NextResponse.redirect(new URL(authRoutes[0], request.url)); } return NextResponse.next(); } export const config = { matcher: [ // Kecuali untuk static files, image, favicon, dan file gambar "/((?!_next/static|_next/image|favicon.ico|.*\\\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], }; Agar route lebih fleksibel, kita pisahkan daftar halaman publik dan auth di file routes.ts: // src/routes.ts export const publicRoutes: string[] = ["/"]; export const authRoutes: string[] = ["/signin", "/signup"]; export const apiAuthPrefix: string = "/api/auth"; export const DEFAULT_LOGIN_REDIRECT: string = "/dashboard"; Halaman Sign Up: Saatnya Pengguna Daftar Nah, sekarang kita bikin halaman supaya pengguna bisa daftar akun mereka. Gak perlu ribet kok, apalagi karena kita udah pakai Better-Auth dan Shadcn UI. Tinggal gabungkan aja komponen UI + logic auth. Buat file src/app/(auth)/signup/page.tsx, lalu isi dengan kode ini: import { type Metadata } from "next"; import Link from "next/link"; import SignUpForm from "./form"; export const metadata: Metadata = { title: "Sign Up", }; export default function SignUpPage() { return ( <div className="flex min-h-screen w-full flex-col items-center justify-center p-10"> <div className="flex w-full flex-col rounded-2xl border border-foreground/10 px-8 py-5 md:w-96"> <h1 className="text-3xl font-bold mb-2">Sign Up</h1> <p> Selamat datang di <strong>BWA Auth</strong> </p> <SignUpForm /> <div className="flex items-center justify-center gap-2"> <small>Sudah punya akun?</small> <Link href={"/signin"} className="text-sm font-bold leading-none"> Sign In </Link> </div> </div> </div> ); } Kemudian buat file src/app/(auth)/signup/form.tsx, lalu isi dengan kode ini: "use client"; import { Form, FormControl, FormLabel, FormField, FormItem, FormMessage, } from "@/components/ui/form"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { signUp } from "@/lib/auth-client"; import { redirect } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { SignUpSchema, SignUpValues } from "./validate"; import InputStartIcon from "../components/input-start-icon"; import InputPasswordContainer from "../components/input-password"; import { cn } from "@/lib/utils"; import { AtSign, MailIcon, UserIcon } from "lucide-react"; import { Label } from "@/components/ui/label"; export default function SignUpForm() { const [isPending, startTransition] = useTransition(); const form = useForm<SignUpValues>({ resolver: zodResolver(SignUpSchema), defaultValues: { name: "", email: "", username: "", password: "", confirmPassword: "", }, }); function onSubmit(data: SignUpValues) { startTransition(async () => { console.log("submit data:", data); const response = await signUp.email(data); if (response.error) { console.log("SIGN_UP:", response.error.status); toast.error(response.error.message); } else { redirect("/"); } }); } const getInputClassName = (fieldName: keyof SignUpValues) => cn( form.formState.errors[fieldName] && "border-destructive/80 text-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/20", ); const genderItems = [ { id: "radio-male", value: "male", label: "Male" }, { id: "radio-female", value: "female", label: "Female" }, ]; return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="z-50 my-8 flex w-full flex-col gap-5" > <FormField control={form.control} name="name" render={({ field }) => ( <FormItem> <FormControl> <InputStartIcon icon={UserIcon}> <Input placeholder="Name" className={cn("peer ps-9", getInputClassName("name"))} disabled={isPending} {...field} /> </InputStartIcon> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="email" render={({ field }) => ( <FormItem> <FormControl> <InputStartIcon icon={MailIcon}> <Input placeholder="Email" className={cn("peer ps-9", getInputClassName("email"))} disabled={isPending} {...field} /> </InputStartIcon> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="username" render={({ field }) => ( <FormItem> <FormControl> <InputStartIcon icon={AtSign}> <Input placeholder="Username" className={cn("peer ps-9", getInputClassName("username"))} disabled={isPending} {...field} /> </InputStartIcon> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormControl> <InputPasswordContainer> <Input className={cn("pe-9", getInputClassName("password"))} placeholder="Password" disabled={isPending} {...field} /> </InputPasswordContainer> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="confirmPassword" render={({ field }) => ( <FormItem> <FormControl> <InputPasswordContainer> <Input className={cn("pe-9", getInputClassName("confirmPassword"))} placeholder="Confirm Password" disabled={isPending} {...field} /> </InputPasswordContainer> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} className="mt-5 w-full"> Sign Up </Button> </form> </Form> ); } Untuk validasi, buat file src/app/(auth)/signup/validate.ts, lalu isikan kode ini: import { z } from "zod"; const disallowedUsernamePatterns = [ "admin", "superuser", "superadmin", "root", "cakfan", "withcakfan", ]; export const SignUpSchema = z .object({ email: z .string() .min(1, { message: "Email is required" }) .email({ message: "Invalid email address" }), name: z.string().min(4, { message: "Must be at least 4 characters" }), username: z .string() .min(4, { message: "Must be at least 4 characters" }) .regex(/^[a-zA-Z0-0_-]+$/, "Only letters, numbers, - and _ allowed") .refine( (username) => { for (const pattern of disallowedUsernamePatterns) { if (username.toLowerCase().includes(pattern)) { return false; } } return true; }, { message: "Username contains disallowed words" }, ), password: z.string().min(8, { message: "Must be at least 8 characters", }), confirmPassword: z.string().min(8, { message: "Must be at least 8 characters", }), }) .refine((data) => data.password === data.confirmPassword, { message: "Passwords don't match", path: ["confirmPassword"], }); export type SignUpValues = z.infer<typeof SignUpSchema>; Sekarang buat file src/app/(auth)/components/input-start-icon.tsx: // src/app/(auth)/components/input-start-icon.tsx import { LucideIcon } from "lucide-react"; export default function InputStartIcon({ children, icon: Icon, }: { children: React.ReactNode; icon: LucideIcon; }) { return ( <div className="space-y-2"> <div className="relative"> {children} <div className="pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-3 text-muted-foreground/80 peer-disabled:opacity-50"> <Icon size={16} strokeWidth={2} aria-hidden="true" /> </div> </div> </div> ); } Kemudian buat file src/app/(auth)/components/input-password.tsx: // src/app/(auth)/components/input-password.tsx "use client"; import { Eye, EyeOff } from "lucide-react"; import { cloneElement, useState, ReactElement, isValidElement } from "react"; interface InputPasswordContainerProps { children: ReactElement<{ type?: string }>; } export default function InputPasswordContainer({ children, }: InputPasswordContainerProps) { const [isVisible, setIsVisible] = useState(false); const toggleVisibility = () => setIsVisible((prevState) => !prevState); return ( <div className="space-y-2"> <div className="relative"> {isValidElement(children) && cloneElement(children, { type: isVisible ? "text" : "password", })} <button className="absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-lg text-muted-foreground/80 outline-offset-2 transition-colors hover:text-foreground focus:z-10 focus-visible:outline-2 focus-visible:outline-ring/70 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50" type="button" onClick={toggleVisibility} aria-label={isVisible ? "Hide password" : "Show password"} aria-pressed={isVisible} > {isVisible ? ( <EyeOff size={16} strokeWidth={2} aria-hidden="true" /> ) : ( <Eye size={16} strokeWidth={2} aria-hidden="true" /> )} </button> </div> </div> ); } Buka halaman Sign Up di http://localhost:3000/signup maka tampilan akan seperti berikut: Halaman sign up Halaman Sign In: Akses Buat yang Sudah Terdaftar Setelah bisa daftar, sekarang waktunya pengguna bisa login. Kita akan bikin halaman Sign In yang simpel, modern, dan tentu saja aman pakai Better-Auth. Buat file src/app/(auth)/signin/page.tsx: // src/app/(auth)/signin/page.tsx import { type Metadata } from "next"; import SignInForm from "./form"; import Link from "next/link"; export const metadata: Metadata = { title: "Sign In", }; export default function SignInPage() { return ( <div className="flex min-h-screen w-full flex-col items-center justify-center"> <div className="flex w-full flex-col rounded-2xl border border-foreground/10 px-8 py-5 md:w-96"> <h1 className="text-3xl font-bold mb-2">Sign In</h1> <p> Selamat datang di <strong>BWA Auth</strong> </p> <SignInForm /> <div className="flex items-center justify-center gap-2"> <small>Don&apos;t have account?</small> <Link href={"/signup"} className="text-sm font-bold leading-none"> Sign Up </Link> </div> </div> </div> ); } Buat file src/app/(auth)/signin/form.tsx: // src/app/(auth)/signin/form.tsx "use client"; import { Form, FormControl, FormField, FormItem, FormMessage, } from "@/components/ui/form"; import { useTransition } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { signIn } from "@/lib/auth-client"; import { useRouter } from "next/navigation"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { SignInSchema, SignInValues } from "./validate"; import InputStartIcon from "../components/input-start-icon"; import InputPasswordContainer from "../components/input-password"; import { cn } from "@/lib/utils"; import { AtSign } from "lucide-react"; export default function SignInForm() { const [isPending, startTransition] = useTransition(); const router = useRouter(); const form = useForm<SignInValues>({ resolver: zodResolver(SignInSchema), defaultValues: { username: "", password: "", }, }); function onSubmit(data: SignInValues) { startTransition(async () => { const response = await signIn.username(data); if (response.error) { console.log("SIGN_IN:", response.error.message); toast.error(response.error.message); } else { router.push("/"); } }); } const getInputClassName = (fieldName: keyof SignInValues) => cn( form.formState.errors[fieldName] && "border-destructive/80 text-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/20" ); return ( <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="z-50 my-8 flex w-full flex-col gap-5" > <FormField control={form.control} name="username" render={({ field }) => ( <FormItem> <FormControl> <InputStartIcon icon={AtSign}> <Input placeholder="Username" className={cn("peer ps-9", getInputClassName("username"))} disabled={isPending} {...field} /> </InputStartIcon> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="password" render={({ field }) => ( <FormItem> <FormControl> <InputPasswordContainer> <Input id="input-23" className={cn("pe-9", getInputClassName("password"))} placeholder="Password" disabled={isPending} {...field} /> </InputPasswordContainer> </FormControl> <FormMessage /> </FormItem> )} /> <Button type="submit" disabled={isPending} className="mt-5 w-full"> Sign In </Button> </form> </Form> ); } Buat file src/app/(auth)/signin/validate.ts: // src/app/(auth)/signin/validate.ts import { z } from "zod"; export const SignInSchema = z.object({ username: z.string().min(4, { message: "Username is required" }), password: z .string() .min(6, { message: "Password lenght at least 6 characters" }), }); export type SignInValues = z.infer<typeof SignInSchema>; Maka tampilan akan seperti ini: Halaman sign in Halaman Dashboard: Hanya user yang bisa lihat Buat file src/app/dashboard/page.tsx: // src/app/dashboard/page.tsx import type { Metadata } from "next"; import { Button } from "@/components/ui/button"; import Link from "next/link"; export const metadata: Metadata = { title: "Dashboard", }; export default function DashboardPage() { return ( <div className="flex flex-col py-5 items-center justify-center"> <div className="w-1/2"> <div className="flex items-center justify-between"> <h1 className="text-2xl font-bold mb-4">Dashboard</h1> <Button> <Link href={"#"}>Tambah</Link> </Button> </div> <p>Hanya dapat diakses oleh user</p> <Link href="/">Beranda</Link> </div> </div> ); } Maka tampilan akan seperti ini: Tampilan dashboard Edit Hompage Buka file src/app/page.tsx lalu ubah jadi seperti berikt: // src/app/page.tsx import { getMe } from "@/actions/user/me"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import SignOutButton from "./(auth)/components/button-signout"; export default async function Home() { const me = await getMe(); return ( <div className="flex flex-col gap-2 min-h-screen items-center justify-center"> <h1 className="text-4xl font-bold">Hello, BuildWithAngga!</h1> <div className="flex items-center gap-2"> <Button> <Link href="/dashboard">Dashboard</Link> </Button> {me ? ( <SignOutButton /> ) : ( <Button variant="secondary"> <Link href="/signin">Sign In</Link> </Button> )} </div> </div> ); } Sekarang buat file src/actions/user/me.ts, isi kode berikut: // src/actions/user/me.ts "use server"; import { db } from "@/db"; import { user, UserType } from "@/db/schema"; import { auth } from "@/lib/auth"; import { eq } from "drizzle-orm"; import { headers } from "next/headers"; export async function getMe(): Promise<UserType | null | undefined> { const session = await auth.api.getSession({ headers: await headers() }); if (!session) { return null; } return (await db.select().from(user).where(eq(user.id, session.user.id)))[0]; } Sekarang buat file src/app(auth)/components/button-signou.tsx, lalu salin kode ini: "use client"; import { useState } from "react"; import { signOut } from "@/lib/auth-client"; import { redirect } from "next/navigation"; import { Button } from "@/components/ui/button"; export default function SignOutButton() { const [isPending, setIsPending] = useState(false); const onSignOut = async () => { setIsPending(true); await signOut({ fetchOptions: { onSuccess: () => { setIsPending(false); redirect("/"); }, }, }); }; return ( <Button disabled={isPending} onClick={onSignOut} variant={"destructive"}> Logout </Button> ); } Buka halaman homepage http://localhost:3000 maka tampilan akan seperti berikut: Tampilan ketika belum sign in Saat kita mengakses halaman http://localhost:3000/dashboard tanpa login, kita akan otomatis dialihkan ke halaman sign in. Sebaliknya, jika sudah login lalu mencoba membuka halaman sign in atau sign up, kita akan langsung diarahkan ke halaman dashboard. Halaman home ketika user sudah sign in 🎉 Penutup Sampai di sini, kita sudah berhasil membangun fondasi aplikasi modern dengan Next.js App Router, Tailwind CSS, Shadcn UI, Supabase, Drizzle ORM, dan Better-Auth. Kita sudah bahas cara: Setup proyek dan install dependency penting,Konfigurasi Supabase dan Drizzle ORM,Setup autentikasi dengan pendekatan yang simpel tapi aman, Dengan tools dan teknik yang sudah kamu pelajari di atas, kamu sekarang punya modal kuat untuk membangun berbagai macam aplikasi web modern — entah itu untuk belajar, portofolio, atau bahkan produk beneran. 🔥 Selanjutnya, kamu bisa eksplorasi lebih jauh: Menambahkan fitur filter dan sorting,Implementasi role-based access control,Atau integrasi payment gateway kalau proyekmu butuh monetisasi. Yang penting: teruslah bereksperimen dan jangan takut untuk mencoba hal baru. Dunia web development berkembang cepat, dan kamu sudah berada di jalur yang tepat. Sampai jumpa di artikel atau tutorial selanjutnya. Semangat berkarya! 🚀✨