Apa itu Metode 50-30-20?
Metode 50-30-20 adalah sistem pembagian penghasilan yang dipopulerkan oleh Senator Elizabeth Warren. Konsepnya simpel: bagi penghasilan bulanan jadi tiga kategori dengan proporsi yang udah ditentukan.
50% untuk Kebutuhan (Needs) - Biaya kost, tagihan listrik dan air, belanja groceries, transportasi, asuransi kesehatan, dan biaya wajib lainnya.
30% untuk Keinginan (Wants) - Makan di restoran, langganan Netflix atau Spotify, gadget baru, jalan-jalan weekend, atau belanja baju.
20% untuk Tabungan dan Investasi (Savings) - Dana darurat, tabungan nikah, bayar utang, atau investasi jangka panjang.
Contoh Breakdown Budget
Misalnya penghasilan bulanan Rp 8.000.000:
- Kebutuhan (50%): Rp 4.000.000 - Kost Rp 2jt, makan Rp 1,2jt, transportasi Rp 500rb, pulsa Rp 200rb, asuransi Rp 100rb
- Keinginan (30%): Rp 2.400.000 - Hiburan Rp 800rb, shopping Rp 1jt, langganan digital Rp 200rb, hobi Rp 400rb
- Tabungan (20%): Rp 1.600.000 - Dana darurat Rp 800rb, investasi Rp 600rb, tabungan tujuan Rp 200rb
Demo Aplikasi Final
Aplikasi budget calculator ini bakal jadi teman terbaik kamu buat ngelola keuangan pake metode 50-30-20.
Fitur Utama:
✅ Input Penghasilan Bulanan - Masukin gaji dan aplikasi otomatis ngitung pembagian budget
✅ Kalkulasi Otomatis 50-30-20 - Langsung ngasih tau alokasi untuk kebutuhan, keinginan, dan tabungan
✅ Tracking Pengeluaran - Catat semua pengeluaran harian dan tau kemana uang kamu mengalir
✅ Visual Gauge per Kategori - Tampilan gauge keren buat liat berapa budget yang udah kepake
✅ Alert Over Budget - Peringatan kalau mulai overspending di salah satu kategori
Tech Stack
Next.js 15.5.5 - Framework React dengan Turbopack builds yang bikin build time 2-5x lebih cepet. Versi terbaru rilis pertengahan 2025 dengan Server Components dan App Router yang lebih efisien.
Better Auth - Library autentikasi framework-agnostic dengan plugin ecosystem lengkap. Setup cuma 5 menit dan udah support 2FA sampe multi-tenancy.
Drizzle ORM - TypeScript ORM ringan (7.4kb) dengan syntax intuitif. Support PostgreSQL, MySQL, SQLite dan udah adopt identity columns sebagai best practice.
Supabase - Hosting database PostgreSQL dengan tier gratis yang generous. Setup simpel tanpa perlu jadi expert database administrator.
Tailwind CSS + shadcn/ui - Tailwind untuk utility-first styling, shadcn/ui untuk komponen beautiful dan accessible yang bisa copy-paste langsung.
Recharts - Library charting untuk visualisasi gauge yang professional dan eye-catching.
Prerequisites
Pemahaman Dasar React & JavaScript - Familiar dengan components, props, state, hooks (useState, useEffect), dan ES6 features (arrow functions, destructuring, async/await).
Node.js 18+ - Next.js 15 butuh minimal versi 18.17. Cek dengan node --version, kalau belum install di nodejs.org.
Akun Supabase (Gratis) - Daftar di supabase.com buat hosting database PostgreSQL.
Text Editor - VS Code atau editor favorit kamu dengan extension JavaScript/TypeScript.
Setelah paham konsep dan tech stack yang bakal kita pake, sekarang waktunya mulai setup project dan coding aplikasi budget calculator ini.
Membuat Project Next.js
Langkah pertama adalah bikin project Next.js baru. Buka terminal kamu dan jalanin command berikut:
npx create-next-app@latest bwa-budget-tracker
Setelah command di atas dijalanin, kamu bakal ditanya beberapa pertanyaan konfigurasi. Ini adalah pilihan yang perlu kamu pilih:
✔ Would you like to use TypeScript? › Yes
✔ Which linter would you like to use? > Biome
✔ Would you like to use Tailwind CSS? › Yes
✔ Would you like to use `src/` directory? › No
✔ Would you like to use App Router? (recommended) › Yes
✔ Would you like to use Turbopack? › Yes
✔ Would you like to customize the import alias (@/*)? › No
Kenapa pilih TypeScript? Karena dia kasih type safety yang bikin kode kamu lebih aman dan mudah di-maintain. Biome bakal bantu kamu detect error dan enforce code style yang konsisten. Tailwind CSS udah jadi standar industri buat utility-first styling. App Router adalah routing system terbaru dari Next.js yang lebih powerful. Turbopack bakal bikin development server kamu jauh lebih cepet.
Proses instalasi biasanya makan waktu beberapa menit tergantung kecepatan internet kamu. Tunggu sampai selesai dan jangan ditutup terminalnya.
Struktur Folder
Setelah project berhasil dibuat, kita perlu setup struktur folder yang rapi dan scalable. Struktur yang bagus bakal bikin project kamu mudah di-maintain dan dipahami sama developer lain.
bwa-budget-tracker/
├── app/
│ ├── (auth)/
│ │ └── signin/
│ │ └── page.tsx
│ ├── dashboard/
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── api/
│ │ └── auth/
│ │ └── [...all]/
│ │ └── route.ts
│ ├── layout.tsx
│ └── page.tsx
├── components/
│ ├── ui/
│ │ ├── button.tsx
│ │ ├── card.tsx
│ │ └── input.tsx
│ └── budget/
│ ├── budget-form.tsx
│ ├── expense-tracker.tsx
│ └── gauge-chart.tsx
├── lib/
│ ├── db/
│ │ ├── schema.ts
│ │ └── index.ts
│ ├── auth/
│ │ └── config.ts
│ └── utils.ts
├── hooks/
│ ├── use-budget.ts
│ └── use-expenses.ts
└── types/
└── index.ts
Mari kita bahas struktur ini lebih detail:
Folder app/ - Ini adalah folder utama yang menggunakan App Router Next.js 15. Folder (auth) dan (dashboard) menggunakan route groups yang nggak akan masuk ke URL tapi berguna buat organize struktur. File api/auth/[...all]/route.ts adalah catch-all route buat Better Auth.
Folder components/ - Tempat semua komponen React kamu. Subfolder ui/ khusus buat komponen shadcn/ui, sedangkan budget/ buat komponen business logic aplikasi kita.
Folder lib/ - Berisi utility functions, konfigurasi database, dan auth setup. Ini adalah tempat logic yang bisa di-reuse.
Folder hooks/ - Custom React hooks buat manage state dan side effects yang kompleks. Ini bakal bikin komponen kita lebih clean.
Folder types/ - TypeScript type definitions buat maintain type safety di seluruh aplikasi.
Buat folder-folder ini secara manual dulu, nanti kita bakal populate dengan file yang dibutuhin di step berikutnya.
Install Dependencies
Sekarang saatnya install semua library yang kita butuhin. Ada dua kategori dependencies: yang dipake di production dan yang cuma dipake saat development.
Dependencies Production:
npm install better-auth drizzle-orm postgres
Package better-auth adalah library autentikasi yang bakal handle login/register. Package drizzle-orm adalah ORM buat interact dengan database. Package postgres adalah driver PostgreSQL yang compatible dengan Drizzle.
npm install recharts date-fns zod react-hook-form
Package recharts buat bikin visualisasi gauge chart. Package date-fns buat manipulasi tanggal yang lebih mudah. Package zod adalah schema validation library yang powerful. Package react-hook-form buat handle form dengan performa optimal.
Development Dependencies:
npm install -D drizzle-kit
Package drizzle-kit adalah CLI tool buat generate migrations dan manage database schema. Flag -D artinya package ini cuma dipake saat development aja.
Setup shadcn/ui:
shadcn/ui punya cara instalasi yang unik karena dia bukan package npm biasa, tapi copy-paste komponen langsung ke project kamu.
npx shadcn@latest init
Command ini bakal nanya beberapa pertanyaan:
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Which color would you like to use as base color? › Zinc
✔ Would you like to use CSS variables for colors? › Yes
Pilih style "New York" karena lebih modern dan clean. Base color "Zinc" memberikan tampilan yang profesional. CSS variables bakal bikin customization lebih flexible.
Setelah init selesai, install komponen yang kita butuhin:
npx shadcn@latest add button card input progress
Command ini bakal download dan copy komponen Button, Card, Input, dan Progress ke folder components/ui/ kamu. Kamu bebas customize komponen-komponen ini sesuai kebutuhan tanpa worry tentang breaking changes dari library.
Verifikasi Instalasi:
Setelah semua dependencies terinstall, cek file package.json kamu. Harusnya terlihat seperti ini:
{
"dependencies": {
"better-auth": "^1.x.x",
"date-fns": "^4.x.x",
"drizzle-orm": "^0.44.x",
"next": "^15.5.x",
"postgres": "^3.x.x",
"react": "^19.x.x",
"react-dom": "^19.x.x",
"react-hook-form": "^7.x.x",
"recharts": "^2.x.x",
"zod": "^3.x.x"
},
"devDependencies": {
"drizzle-kit": "^0.30.x",
"typescript": "^5.x.x"
}
}
Jalanin npm run dev buat pastiin semua berjalan lancar. Buka browser di http://localhost:3000 dan kamu harusnya liat Next.js welcome page. Kalau udah muncul, berarti setup project kamu berhasil!

Setup project sudah selesai. Sekarang kita punya fondasi yang solid dengan struktur folder yang rapi dan semua dependencies yang dibutuhin. Next step adalah konfigurasi database dan setup autentikasi dengan Better Auth.
Membuat Project Supabase
Sekarang kita perlu setup database buat nyimpen data budget dan pengeluaran user. Kita bakal pake Supabase karena gratis dan gampang dipake.
Step 1: Buat Project Baru
Login ke dashboard Supabase di supabase.com, terus klik tombol "New Project".
Step 2: Isi Konfigurasi Project

Kamu bakal diminta isi beberapa informasi:
- Name: Kasih nama project, misalnya "BWA Budget Tracker"
- Database Password: Bikin password yang kuat dan simpan baik-baik
- Region: Pilih Singapore atau Tokyo (deket Indonesia, latency lebih bagus)
- Pricing Plan: Pilih Free tier (udah lebih dari cukup)
Proses pembuatan project biasanya butuh waktu 1-2 menit. Tunggu sampe status project berubah jadi "Active".
Step 3: Copy DATABASE_URL

Setelah project jadi, ambil connection string dengan cara:
- Klik tombol "Connect" di bagian atas dashboard
- Modal akan muncul dengan beberapa opsi connection
- Pilih "Transaction pooler" (recommended untuk aplikasi backend)
- Copy connection string yang muncul
Kamu bakal dapet connection string dengan format:
postgresql://postgres.[project-ref]:[password]@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres
Ganti [password] dengan password database yang kamu bikin tadi.
Note penting tentang connection pooler:
Supabase menyediakan beberapa jenis connection:
- Transaction pooler - Recommended buat serverless dan backend apps (port 5432)
- Session pooler - Buat compatibility IPv4 dan prepared statements
- Direct connection - Cuma buat persistent servers kayak VMs
Untuk project kita, pake Transaction pooler karena paling optimal buat aplikasi web modern.
Setup Environment Variables
Buat file .env di root project kamu (kalau belum ada), terus tambahkan:
DATABASE_URL="postgresql://postgres.[project-ref]:[password]@aws-0-ap-southeast-1.pooler.supabase.com:5432/postgres"
BETTER_AUTH_SECRET="generate-random-32-char-string"
BETTER_AUTH_URL="<http://localhost:3000>"
DATABASE_URL dipake buat koneksi ke database lewat Transaction pooler. Ganti [project-ref] dan [password] dengan value dari connection string yang kamu copy tadi.
Buat BETTER_AUTH_SECRET, kamu bisa generate random string pake command ini:
openssl rand -base64 32
Atau pake online generator di website Better Auth. Pastiin string-nya minimal 32 karakter buat security yang lebih baik.
Database Schema
Sekarang kita definisikan struktur database yang bakal kita pake. Kita perlu bikin schema buat user authentication dan budget tracking.
Buat file lib/db/schema.ts:
import { pgTable, text, integer, timestamp, decimal, uuid } from "drizzle-orm/pg-core";
// Tabel untuk budget utama
export const budgets = pgTable("budgets", {
id: uuid("id").primaryKey().defaultRandom(),
userId: text("user_id").notNull(),
monthlyIncome: decimal("monthly_income", { precision: 12, scale: 2 }).notNull(),
needsAmount: decimal("needs_amount", { precision: 12, scale: 2 }).notNull(),
wantsAmount: decimal("wants_amount", { precision: 12, scale: 2 }).notNull(),
savingsAmount: decimal("savings_amount", { precision: 12, scale: 2 }).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}).enableRLS();
// Tabel untuk tracking pengeluaran
export const expenses = pgTable("expenses", {
id: uuid("id").primaryKey().defaultRandom(),
budgetId: uuid("budget_id").notNull().references(() => budgets.id, { onDelete: "cascade" }),
userId: text("user_id").notNull(),
category: text("category").notNull(), // 'needs', 'wants', 'savings'
amount: decimal("amount", { precision: 12, scale: 2 }).notNull(),
description: text("description").notNull(),
date: timestamp("date").defaultNow().notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
}).enableRLS();
// Tabel untuk Better Auth - User
export const user = pgTable("user", {
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: integer("email_verified", { mode: "boolean" }).notNull().default(false),
image: text("image"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
}).enableRLS();
// Tabel untuk Better Auth - Session
export const session = pgTable("session", {
id: text("id").primaryKey(),
expiresAt: timestamp("expires_at").notNull(),
token: text("token").notNull().unique(),
createdAt: timestamp("created_at").notNull(),
updatedAt: timestamp("updated_at").notNull(),
ipAddress: text("ip_address"),
userAgent: text("user_agent"),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
}).enableRLS();
// Tabel untuk Better Auth - Account
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").notNull(),
updatedAt: timestamp("updated_at").notNull(),
}).enableRLS();
// Tabel untuk Better Auth - 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"),
updatedAt: timestamp("updated_at"),
}).enableRLS();
Schema di atas mencakup semua tabel yang kita butuhin. Tabel budgets buat nyimpen penghasilan bulanan dan pembagian 50-30-20. Tabel expenses buat tracking semua pengeluaran. Sisanya adalah tabel yang dibutuhin Better Auth buat handle authentication.
Perhatiin kita pake UUID dengan .defaultRandom() buat tabel budget dan expenses karena lebih aman dan standard buat aplikasi production. Untuk tabel Better Auth (user, account, session, verification) kita pake text ID sesuai bawaan dari Better Auth yang generate ID sendiri. Kita juga pake decimal type buat amount karena lebih presisi buat data finansial dibanding float. Method .enableRLS() di setiap tabel aktifin Row Level Security buat keamanan data, jadi nanti setiap user cuma bisa akses data mereka sendiri.
Konfigurasi Drizzle
Setelah schema siap, kita perlu setup koneksi database dengan Drizzle ORM.
Buat file konfigurasi Drizzle
Bikin file drizzle.config.ts di root project:
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: "postgresql",
schema: "./lib/db/schema.ts",
out: "./drizzle",
dbCredentials: {
url: process.env.DIRECT_DATABASE_URL as string,
},
});
Config ini ngasih tau Drizzle dimana schema kita, database apa yang kita pake (PostgreSQL), dan kemana file migration harus disimpen. Kita pake DIRECT_DATABASE_URL karena migrations butuh direct connection ke database, bukan lewat connection pooler.
Setup database connection
Buat file lib/db/index.ts:
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const client = postgres(process.env.DATABASE_URL as string);
export const db = drizzle(client, { schema });
File ini setup koneksi database pake postgres client dan initialize Drizzle dengan schema yang udah kita buat. Kita pass schema object supaya Drizzle bisa ngelakuin type inference dengan baik dan ngasih autocomplete di editor.
Run Migrations
Sekarang waktunya apply schema kita ke database Supabase. Drizzle punya dua command penting buat ini.
Generate migration files
Jalanin command ini buat generate SQL migration files berdasarkan schema kita:
npx drizzle-kit generate
Command ini bakal:
- Baca schema dari
lib/db/schema.ts - Compare dengan state database sebelumnya
- Generate SQL migration files di folder
drizzle/
Kamu bakal liat output kayak gini:
📦 <project root>
└ 📂 drizzle
├ 📂 _meta
└ 📜 0000_init.sql
File .sql ini berisi semua SQL commands buat create tables yang kita definisiin di schema. Kamu bisa buka dan liat isinya, tapi jangan edit manual kecuali kamu tau apa yang kamu lakuin.
Apply migrations ke database
Setelah migration files ter-generate, apply ke database dengan command:
npx drizzle-kit migrate
Command ini bakal:
- Connect ke database Supabase pake
DATABASE_URL - Baca migration files di folder
drizzle/ - Execute SQL commands di database
- Track migration history di tabel
__drizzle_migrations__
Kalau berhasil, kamu bakal liat message:
✓ Applying migrations...
✓ Migrations applied successfully
Verifikasi di Supabase Dashboard
Buka Supabase dashboard, pergi ke "Table Editor" di sidebar. Kamu harusnya bisa liat semua tabel yang baru dibuat: budgets, expenses, user, session, account, dan verification.
Kalau ada error, cek lagi:
- DATABASE_URL udah bener belum di file
.env - Password database udah diganti dari placeholder
- Koneksi internet stabil
- Project Supabase masih aktif
Tips Pro: Setiap kali kamu ubah schema di lib/db/schema.ts, kamu perlu run npx drizzle-kit generate lagi buat bikin migration baru, terus npx drizzle-kit migrate buat apply changes ke database.
Database setup sudah selesai! Sekarang kita punya database yang siap dipake dengan struktur tabel yang proper. Next step adalah setup Better Auth buat handle user registration dan login.
Setup Better Auth
Sekarang kita masuk ke bagian yang penting: authentication. Kita bakal setup Better Auth buat handle login, register, dan session management. Better Auth ini powerful tapi mudah dipake, cocok banget buat pemula.
Buat file konfigurasi Better Auth
Bikin file lib/auth/index.ts:
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { nextCookies } from "better-auth/next-js";
import { db } from "../db";
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg",
}),
emailAndPassword: {
enabled: true,
},
plugins: [nextCookies()],
});
Konfigurasi di atas cukup straightforward. Kita pake Drizzle adapter buat koneksi ke database PostgreSQL, aktifin email dan password authentication, dan tambahin plugin nextCookies supaya Better Auth bisa set cookies otomatis di Next.js. Plugin ini harus dipasang terakhir di array plugins.
Setting emailAndPassword.enabled ke true ngasih fitur login pake email dan password tanpa perlu setup OAuth provider dulu. Simpel dan cepet buat mulai.
Auth API Route
Better Auth butuh API route buat handle semua request authentication kayak login, register, dan get session. Kita bakal pake catch-all route handler dari Next.js.
Buat catch-all route handler
Bikin file app/api/auth/[...all]/route.ts:
import { auth } from "@/lib/auth";
import { toNextJsHandler } from "better-auth/next-js";
export const { GET, POST } = toNextJsHandler(auth.handler);
File ini super simple tapi powerful. Function toNextJsHandler nge-convert Better Auth handler jadi format yang Next.js App Router bisa pahami. Route ini bakal handle semua endpoints Better Auth secara otomatis di /api/auth/*.
Jangan ubah path /api/auth/[...all] kecuali kamu tau apa yang kamu lakuin, karena ini adalah convention yang direkomendasiin Better Auth buat kompatibilitas maksimal.
Halaman Login
Sekarang kita bikin UI buat user login. Kita bakal pake shadcn/ui components yang udah kita install sebelumnya dengan design yang clean, modern, dan menarik.
Buat halaman signin
Bikin file app/(auth)/signin/page.tsx:
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
export default function SignInPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [isRegister, setIsRegister] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const email = formData.get("email") as string;
const password = formData.get("password") as string;
const name = formData.get("name") as string;
try {
if (isRegister) {
await authClient.signUp.email({ email, password, name });
} else {
await authClient.signIn.email({ email, password });
}
router.push("/dashboard");
} catch (err: any) {
setError(err?.message || "Terjadi kesalahan. Silakan coba lagi.");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary/10 via-background to-primary/5 flex items-center justify-center p-6">
<Card className="w-full max-w-md border border-border/40 shadow-lg bg-card/95 backdrop-blur-sm">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-3xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
{isRegister ? "Buat Akun" : "Selamat Datang Kembali"}
</CardTitle>
<CardDescription className="text-muted-foreground">
{isRegister
? "Daftar untuk mulai mengelola budget dengan metode 50-30-20"
: "Masuk ke akun BuildWithAngga Budget Tracker kamu"}
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
{isRegister && (
<div className="space-y-2">
<Label htmlFor="name">Nama Lengkap</Label>
<Input
id="name"
name="name"
type="text"
placeholder="John Doe"
required
disabled={isLoading}
/>
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Minimal 8 karakter"
required
disabled={isLoading}
minLength={8}
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<Button
type="submit"
className="w-full text-base font-medium"
disabled={isLoading}
>
{isLoading ? "Memproses..." : isRegister ? "Daftar" : "Masuk"}
</Button>
</form>
<div className="mt-5 text-center text-sm text-muted-foreground">
{isRegister ? "Sudah punya akun? " : "Belum punya akun? "}
<button
type="button"
onClick={() => {
setIsRegister(!isRegister);
setError("");
}}
className="font-medium text-primary hover:text-primary/80 underline-offset-4 hover:underline transition-colors"
disabled={isLoading}
>
{isRegister ? "Masuk" : "Daftar"}
</button>
</div>
</CardContent>
</Card>
</div>
);
}

Halaman Register
Selain halaman login, kita juga perlu halaman register buat user baru yang mau daftar.
Buat halaman signup
Bikin file app/(auth)/signup/page.tsx:
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
export default function SignUpPage() {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const name = formData.get("name") as string;
const email = formData.get("email") as string;
const password = formData.get("password") as string;
try {
await authClient.signUp.email({ email, password, name });
router.push("/dashboard");
} catch (err: any) {
setError(err?.message || "Terjadi kesalahan. Silakan coba lagi.");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen bg-gradient-to-br from-primary/10 via-background to-primary/5 flex items-center justify-center p-6">
<Card className="w-full max-w-md border border-border/40 shadow-lg bg-card/95 backdrop-blur-sm">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-3xl font-bold tracking-tight bg-gradient-to-r from-primary to-primary/70 bg-clip-text text-transparent">
Buat Akun Baru
</CardTitle>
<CardDescription className="text-muted-foreground">
Daftar untuk mulai mengelola budget dengan metode 50-30-20
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="name">Nama Lengkap</Label>
<Input
id="name"
name="name"
type="text"
placeholder="John Doe"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
placeholder="[email protected]"
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
placeholder="Minimal 8 karakter"
required
disabled={isLoading}
minLength={8}
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<Button
type="submit"
className="w-full text-base font-medium"
disabled={isLoading}
>
{isLoading ? "Membuat akun..." : "Daftar Sekarang"}
</Button>
</form>
<div className="mt-5 text-center text-sm text-muted-foreground">
Sudah punya akun?{" "}
<button
type="button"
onClick={() => router.push("/sign-in")}
className="font-medium text-primary hover:text-primary/80 underline-offset-4 hover:underline transition-colors"
disabled={isLoading}
>
Masuk di sini
</button>
</div>
</CardContent>
</Card>
</div>
);
}

Auth Client
Kita perlu setup auth client buat handle authentication di client side. Client ini bakal dipake di semua komponen yang butuh akses ke authentication.
Buat auth client
Bikin file lib/auth-client.ts:
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL || "<http://localhost:3000>",
});
export const { useSession, signOut } = authClient;
Client ini sangat simple tapi powerful. Function createAuthClient bikin instance client yang bisa dipake di semua komponen React. Kita export useSession dan signOut supaya bisa langsung dipake tanpa perlu import authClient terus.
Setup environment variable
Tambahin environment variable di .env:
NEXT_PUBLIC_APP_URL="<http://localhost:3000>"
Variable NEXT_PUBLIC_APP_URL ini penting buat Better Auth tau dimana base URL aplikasi kita. Prefix NEXT_PUBLIC_ bikin variable ini bisa diakses di client side.
Install komponen shadcn/ui
Jangan lupa install komponen Label yang kita pake di form:
npx shadcn@latest add label
Komponen Label dari shadcn/ui ngasih styling yang konsisten dan accessibility yang baik buat semua form input.
Protected Routes
Terakhir, kita perlu protect halaman dashboard supaya cuma user yang udah login aja yang bisa akses. Kita pake Next.js middleware buat ini.
Buat middleware
Bikin file middleware.ts di root project:
import { NextRequest, NextResponse } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Protected routes yang butuh authentication
const protectedRoutes = ["/dashboard"];
const isProtectedRoute = protectedRoutes.some((route) =>
pathname.startsWith(route)
);
// Auth routes yang nggak boleh diakses kalau udah login
const authRoutes = ["/signin", "/signup"];
const isAuthRoute = authRoutes.some((route) => pathname.startsWith(route));
if (isProtectedRoute || isAuthRoute) {
const sessionCookie = getSessionCookie(request);
// Redirect ke signin kalau belum login tapi akses protected route
if (isProtectedRoute && !sessionCookie) {
const signInUrl = new URL("/signin", request.url);
signInUrl.searchParams.set("callbackUrl", pathname);
return NextResponse.redirect(signInUrl);
}
// Redirect ke dashboard kalau udah login tapi akses auth route
if (isAuthRoute && sessionCookie) {
return NextResponse.redirect(new URL("/dashboard", request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Middleware ini ngelakuin beberapa hal penting:
Check Protected Routes - Kalau user coba akses /dashboard tanpa login, langsung di-redirect ke /signin dengan callback URL buat redirect balik setelah login.
Check Auth Routes - Kalau user udah login tapi coba akses /signin atau /signup, langsung di-redirect ke /dashboard supaya nggak bingung.
Session Cookie Check - Pake getSessionCookie buat cek keberadaan session cookie. Ini adalah optimistic check yang cepet dan efisien buat redirect user.
Matcher Config - Middleware cuma jalan di routes yang penting, nggak di static files atau API routes buat performa yang lebih baik.
Security Note: Function getSessionCookie cuma ngecek keberadaan cookie, nggak validasi isinya. Ini aman buat redirect aja, tapi kamu tetep harus validasi session yang beneran di setiap protected page atau API route. Better Auth udah handle validasi session di server secara otomatis pas kamu pake auth.api.getSession() di komponen atau API.
Authentication sudah selesai! Sekarang aplikasi kita udah punya sistem login dan register yang aman, plus protected routes buat halaman dashboard. User nggak bisa akses dashboard kalau belum login, dan kalau udah login nggak bakal bisa balik ke halaman signin atau signup. Next step adalah bikin halaman dashboard dan form budget calculator-nya.
Budget Input Form
Sekarang kita masuk ke bagian inti dari aplikasi: komponen budget calculator. Kita bakal mulai dengan form input penghasilan yang otomatis nge-calculate pembagian budget 50-30-20.
Buat komponen Budget Input Form
Bikin file components/budget/BudgetInputForm.tsx:
"use client";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
interface BudgetInputFormProps {
onSubmit: (data: {
monthlyIncome: number;
needsAmount: number;
wantsAmount: number;
savingsAmount: number;
}) => Promise<void>;
}
export function BudgetInputForm({ onSubmit }: BudgetInputFormProps) {
const router = useRouter();
const [income, setIncome] = useState("");
const [displayValue, setDisplayValue] = useState("");
const [isLoading, setIsLoading] = useState(false);
// Kalkulasi otomatis berdasarkan metode 50-30-20
const monthlyIncome = parseFloat(income) || 0;
const needs = monthlyIncome * 0.5; // 50%
const wants = monthlyIncome * 0.3; // 30%
const savings = monthlyIncome * 0.2; // 20%
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(value);
};
const formatNumber = (value: string) => {
// Remove semua karakter non-digit
const numbers = value.replace(/\\D/g, "");
// Format dengan titik sebagai separator ribuan
return numbers.replace(/\\B(?=(\\d{3})+(?!\\d))/g, ".");
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Remove semua karakter non-digit
const numbers = value.replace(/\\D/g, "");
// Set value mentah (tanpa format) untuk calculation
setIncome(numbers);
// Set display value (dengan format)
setDisplayValue(formatNumber(numbers));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (monthlyIncome <= 0) {
return;
}
setIsLoading(true);
try {
await onSubmit({
monthlyIncome,
needsAmount: needs,
wantsAmount: wants,
savingsAmount: savings,
});
router.refresh();
} catch (error) {
console.error("Error saving budget:", error);
} finally {
setIsLoading(false);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Input Penghasilan */}
<div className="space-y-2">
<Label htmlFor="income" className="text-base font-semibold">
Penghasilan Bulanan
</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground font-medium">
Rp
</span>
<Input
id="income"
type="text"
placeholder="8.000.000"
value={displayValue}
onChange={handleInputChange}
className="pl-9 text-lg h-12"
disabled={isLoading}
required
/>
</div>
<p className="text-sm text-muted-foreground">
Masukkan total penghasilan bersih per bulan
</p>
</div>
{/* Preview Alokasi Budget */}
{monthlyIncome > 0 && (
<div className="space-y-3 pt-4 border-t">
<h3 className="font-semibold text-base mb-3">
Preview Alokasi Budget
</h3>
{/* Kebutuhan 50% */}
<div className="flex items-center justify-between p-4 rounded-lg bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center text-2xl">
🏠
</div>
<div>
<p className="font-semibold text-blue-900 dark:text-blue-100">
Kebutuhan
</p>
<p className="text-sm text-blue-700 dark:text-blue-300">
50% dari penghasilan
</p>
</div>
</div>
<p className="text-lg font-bold text-blue-900 dark:text-blue-100">
{formatCurrency(needs)}
</p>
</div>
{/* Keinginan 30% */}
<div className="flex items-center justify-between p-4 rounded-lg bg-purple-50 dark:bg-purple-950 border border-purple-200 dark:border-purple-800">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900 flex items-center justify-center text-2xl">
🎮
</div>
<div>
<p className="font-semibold text-purple-900 dark:text-purple-100">
Keinginan
</p>
<p className="text-sm text-purple-700 dark:text-purple-300">
30% dari penghasilan
</p>
</div>
</div>
<p className="text-lg font-bold text-purple-900 dark:text-purple-100">
{formatCurrency(wants)}
</p>
</div>
{/* Tabungan 20% */}
<div className="flex items-center justify-between p-4 rounded-lg bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800">
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center text-2xl">
💰
</div>
<div>
<p className="font-semibold text-green-900 dark:text-green-100">
Tabungan
</p>
<p className="text-sm text-green-700 dark:text-green-300">
20% dari penghasilan
</p>
</div>
</div>
<p className="text-lg font-bold text-green-900 dark:text-green-100">
{formatCurrency(savings)}
</p>
</div>
</div>
)}
{/* Info Card */}
<div className="p-4 bg-muted rounded-lg border">
<p className="text-sm text-muted-foreground leading-relaxed">
<span className="font-semibold text-foreground">Metode 50-30-20</span>{" "}
adalah strategi pembagian keuangan yang simpel dan efektif. Budget
kamu akan otomatis dibagi menjadi tiga kategori untuk membantu
mengatur keuangan dengan lebih baik.
</p>
</div>
{/* Submit Button */}
<Button
type="submit"
className="w-full h-12 text-base font-semibold"
disabled={isLoading || monthlyIncome <= 0}
>
{isLoading ? "Menyimpan Budget..." : "Buat Budget Sekarang"}
</Button>
</form>
);
}

Form ini punya beberapa fitur penting:
Auto Calculate - Setiap kali user input penghasilan, form langsung calculate pembagian budget secara realtime. Pake formula sederhana: kebutuhan 50%, keinginan 30%, tabungan 20%.
Format Currency - Angka di-format ke format Rupiah pake Intl.NumberFormat supaya lebih mudah dibaca. Jadi 5000000 jadi Rp 5.000.000.
Preview Alokasi - User langsung bisa liat pembagian budget sebelum di-save. Ini bikin user lebih paham kemana uang mereka bakal dialokasiin.
Validation - Input cuma bisa accept angka positif dengan step 1000 buat kemudahan input. Plus ada disabled state pas loading.
Komponen ini nerima prop onSubmit yang nanti bakal kita implement di halaman dashboard buat save data ke database.
Circular Gauge Component
Komponen gauge ini bakal nampilin progress pengeluaran dalam bentuk circular chart yang gampang dipahami. Kita pake Recharts RadialBarChart dengan color coding buat visual feedback.
Install Recharts
Pertama, install dulu package Recharts:
npx shadcn@latest add chart
Buat komponen Circular Gauge
Bikin file components/budget/CircularGauge.tsx:
"use client";
import { Label, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts";
import { type ChartConfig, ChartContainer } from "@/components/ui/chart";
interface CircularGaugeProps {
spent: number;
allocated: number;
category: "needs" | "wants" | "savings";
}
export function CircularGauge({
spent,
allocated,
category,
}: CircularGaugeProps) {
const percentage = allocated > 0 ? (spent / allocated) * 100 : 0;
const cappedPercentage = Math.min(percentage, 100);
const getColor = (pct: number) => {
if (pct <= 70) return "hsl(142, 71%, 45%)"; // Hijau
if (pct <= 90) return "hsl(48, 96%, 53%)"; // Kuning
return "hsl(0, 84%, 60%)"; // Merah
};
const getCategoryColor = () => {
const colors = {
needs: "hsl(217, 91%, 60%)", // Blue
wants: "hsl(271, 91%, 65%)", // Purple
savings: "hsl(142, 71%, 45%)", // Green
};
return colors[category];
};
const color = getColor(percentage);
const chartData = [
{
category: category,
value: cappedPercentage,
fill: color,
},
];
const chartConfig = {
value: {
label: "Pengeluaran",
},
[category]: {
label:
category === "needs"
? "Kebutuhan"
: category === "wants"
? "Keinginan"
: "Tabungan",
color: getCategoryColor(),
},
} satisfies ChartConfig;
return (
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square w-full max-w-[200px]"
>
<RadialBarChart
data={chartData}
startAngle={90}
endAngle={-270}
innerRadius={60}
outerRadius={80}
>
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 10}
className="fill-foreground text-3xl font-bold"
>
{Math.round(percentage)}%
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 15}
className="fill-muted-foreground text-sm"
>
terpakai
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
<RadialBar
dataKey="value"
cornerRadius={10}
fill={color}
className="stroke-transparent stroke-2"
/>
</RadialBarChart>
</ChartContainer>
);
}
Komponen gauge punya beberapa fitur keren:
Color Coding - Warna gauge berubah otomatis berdasarkan persentase:
- Hijau (0-70%): Budget masih aman
- Kuning (71-90%): Mulai hati-hati, hampir habis
- Merah (91-100%): Over budget atau hampir over
Dynamic Percentage - Angka persentase di tengah gauge update realtime sesuai pengeluaran. Warnanya juga ikut berubah sesuai status budget.
Responsive Design - Pake ResponsiveContainer dari Recharts supaya chart otomatis adjust ke ukuran container. Jadi tetep bagus di semua device.
Smooth Corners - Property cornerRadius={10} bikin ujung bar jadi rounded, lebih modern dan clean.
Background Bar - Ada background bar abu-abu yang nunjukkin total 100%, jadi user bisa liat berapa sisa budget yang belum kepake.
Komponen ini bakal kita pake di CategoryCard buat nampilin progress setiap kategori budget.
Category Card
Komponen card ini bakal nampilin summary dari setiap kategori budget (Kebutuhan, Keinginan, Tabungan) dengan visualisasi gauge yang keren.
Buat komponen Category Card
Bikin file components/budget/CategoryCard.tsx:
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CircularGauge } from "./CircularGauge";
interface CategoryCardProps {
category: "needs" | "wants" | "savings";
allocated: number;
spent: number;
}
export function CategoryCard({ category, allocated, spent }: CategoryCardProps) {
const remaining = allocated - spent;
const isOverBudget = spent > allocated;
// Config untuk setiap kategori
const categoryConfig = {
needs: {
title: "Kebutuhan",
description: "Biaya wajib & kebutuhan dasar",
icon: "🏠",
bgColor: "bg-blue-50 dark:bg-blue-950",
},
wants: {
title: "Keinginan",
description: "Hiburan & lifestyle",
icon: "🎮",
bgColor: "bg-purple-50 dark:bg-purple-950",
},
savings: {
title: "Tabungan",
description: "Dana darurat & investasi",
icon: "💰",
bgColor: "bg-green-50 dark:bg-green-950",
},
};
const config = categoryConfig[category];
const formatCurrency = (value: number) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(value);
};
return (
<Card className={config.bgColor}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="text-2xl">{config.icon}</span>
<div>
<h3 className="text-xl font-bold">{config.title}</h3>
<p className="text-sm text-muted-foreground font-normal">
{config.description}
</p>
</div>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Circular Gauge */}
<CircularGauge spent={spent} allocated={allocated} />
{/* Budget Details */}
<div className="space-y-2 pt-4 border-t">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Dialokasikan</span>
<span className="font-semibold">{formatCurrency(allocated)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Terpakai</span>
<span className="font-semibold">{formatCurrency(spent)}</span>
</div>
<div className="flex justify-between text-base font-bold pt-2 border-t">
<span>Sisa</span>
<span className={isOverBudget ? "text-destructive" : "text-green-600"}>
{formatCurrency(Math.abs(remaining))}
{isOverBudget && " (Over)"}
</span>
</div>
</div>
{/* Warning Alert */}
{isOverBudget && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-md">
<p className="text-sm text-destructive font-medium">
⚠️ Pengeluaran melebihi budget!
</p>
</div>
)}
</CardContent>
</Card>
);
}
Category Card punya fitur lengkap:
Kategori Unik - Setiap kategori punya icon, warna, dan deskripsi sendiri. Kebutuhan pake icon rumah (biru), Keinginan pake gamepad (ungu), Tabungan pake money bag (hijau).
Visual Gauge - Pake komponen CircularGauge yang udah kita buat sebelumnya buat nampilin progress pengeluaran secara visual.
Budget Breakdown - Nampilin 3 angka penting:
- Allocated: Total budget yang dialokasiin
- Terpakai: Jumlah yang udah kepake
- Sisa: Sisa budget yang masih available
Over Budget Alert - Kalau pengeluaran udah melebihi budget, muncul alert warning merah dengan pesan yang jelas. Plus angka sisa jadi merah dan nunjukkin berapa over-nya.
Dark Mode Support - Background color punya variant dark mode supaya tetep bagus di tema gelap.
Format Rupiah - Semua angka di-format pake format Rupiah Indonesia supaya familiar dan mudah dibaca.
Card ini bakal jadi komponen utama di dashboard buat nampilin status budget di setiap kategori.
Transaction Form
Komponen terakhir adalah form buat input transaksi pengeluaran. User bisa catat setiap pengeluaran dengan kategori, jumlah, deskripsi, dan tanggal.
Install komponen Select
Pertama install dulu komponen Select dari shadcn/ui:
npx shadcn@latest add select
Buat komponen Transaction Form
Bikin file components/budget/TransactionForm.tsx:
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
interface TransactionFormProps {
budgetId: string;
onSubmit: (data: {
budgetId: string;
category: string;
amount: number;
description: string;
date: Date;
}) => Promise<void>;
}
export function TransactionForm({ budgetId, onSubmit }: TransactionFormProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
category: "",
amount: "",
description: "",
date: new Date().toISOString().split("T")[0],
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsLoading(true);
try {
await onSubmit({
budgetId,
category: formData.category,
amount: parseFloat(formData.amount),
description: formData.description,
date: new Date(formData.date),
});
// Reset form setelah berhasil
setFormData({
category: "",
amount: "",
description: "",
date: new Date().toISOString().split("T")[0],
});
router.refresh();
} catch (error) {
console.error("Error saving transaction:", error);
} finally {
setIsLoading(false);
}
};
return (
<Card>
<CardHeader>
<CardTitle>Tambah Pengeluaran</CardTitle>
<CardDescription>
Catat setiap pengeluaran untuk tracking budget yang lebih akurat
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category">Kategori</Label>
<Select
value={formData.category}
onValueChange={(value) =>
setFormData({ ...formData, category: value })
}
disabled={isLoading}
>
<SelectTrigger id="category">
<SelectValue placeholder="Pilih kategori" />
</SelectTrigger>
<SelectContent>
<SelectItem value="needs">🏠 Kebutuhan</SelectItem>
<SelectItem value="wants">🎮 Keinginan</SelectItem>
<SelectItem value="savings">💰 Tabungan</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="amount">Jumlah</Label>
<Input
id="amount"
type="number"
placeholder="50000"
value={formData.amount}
onChange={(e) =>
setFormData({ ...formData, amount: e.target.value })
}
required
disabled={isLoading}
min="0"
step="1000"
/>
<p className="text-sm text-muted-foreground">
Masukkan jumlah dalam Rupiah
</p>
</div>
<div className="space-y-2">
<Label htmlFor="description">Deskripsi</Label>
<Input
id="description"
type="text"
placeholder="Contoh: Belanja groceries"
value={formData.description}
onChange={(e) =>
setFormData({ ...formData, description: e.target.value })
}
required
disabled={isLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="date">Tanggal</Label>
<Input
id="date"
type="date"
value={formData.date}
onChange={(e) =>
setFormData({ ...formData, date: e.target.value })
}
required
disabled={isLoading}
max={new Date().toISOString().split("T")[0]}
/>
</div>
<Button
type="submit"
className="w-full"
disabled={isLoading || !formData.category || !formData.amount}
>
{isLoading ? "Menyimpan..." : "Tambah Pengeluaran"}
</Button>
</form>
</CardContent>
</Card>
);
}

Transaction Form punya fitur lengkap:
Dropdown Kategori - Pake Select component dari shadcn/ui dengan icon buat setiap kategori. User gampang pilih mau catat pengeluaran di kategori mana.
Input Validation - Semua field required dan ada validasi:
- Amount cuma terima angka positif dengan step 1000
- Date nggak bisa pilih tanggal masa depan (max=today)
- Description wajib diisi buat tracking yang lebih jelas
Auto Reset - Setelah transaksi berhasil disimpen, form otomatis reset ke state awal. Jadi user bisa langsung input transaksi berikutnya tanpa clear manual.
Loading State - Pas lagi submit, semua input jadi disabled dan button nunjukkin loading text. Ini prevent double submission.
Format Input - Amount pake step 1000 buat kemudahan input angka bulat ribuan.
Default Date - Date field default-nya udah ke-set ke hari ini, jadi user nggak perlu input manual kalau pengeluaran hari ini.
Form ini bakal dipake di dashboard buat user catat pengeluaran harian mereka. Data dari form ini bakal masuk ke database dan langsung update gauge di CategoryCard.
Semua komponen budget sudah selesai! Sekarang kita punya form input budget, circular gauge buat visual progress, category card yang informatif, dan transaction form buat tracking pengeluaran. Next step adalah bikin halaman dashboard yang ngegabungin semua komponen ini jadi satu aplikasi yang utuh.
Server Actions untuk Budget
Setelah autentikasi berhasil, sekarang kita masuk ke inti aplikasi: dashboard tempat user bakal manage budget mereka. Tapi sebelum bikin UI-nya, kita perlu setup Server Actions dulu buat handle semua operasi database.
Server Actions adalah fitur powerful dari Next.js yang bikin kamu bisa nulis server-side logic langsung tanpa perlu bikin API routes terpisah. Di Next.js 15, Server Actions udah lebih aman dengan fitur secure action IDs dan dead code elimination.
Buat file app/actions/budget.ts:
'use server'
import { db } from "@/lib/db";
import { budgets, expenses } from "@/lib/db/schema";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { eq, and, desc } from "drizzle-orm";
import { revalidatePath } from "next/cache";
export async function createBudget(data: {
monthlyIncome: number;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
throw new Error("Unauthorized");
}
const needsAmount = data.monthlyIncome * 0.5;
const wantsAmount = data.monthlyIncome * 0.3;
const savingsAmount = data.monthlyIncome * 0.2;
const [budget] = await db
.insert(budgets)
.values({
userId: session.user.id,
monthlyIncome: data.monthlyIncome.toString(),
needsAmount: needsAmount.toString(),
wantsAmount: wantsAmount.toString(),
savingsAmount: savingsAmount.toString(),
})
.returning();
revalidatePath("/");
return budget;
}
export async function getBudget() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return null;
}
const [budget] = await db
.select()
.from(budgets)
.where(eq(budgets.userId, session.user.id))
.orderBy(desc(budgets.createdAt))
.limit(1);
return budget;
}
export async function getTransactions() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
return [];
}
const budget = await getBudget();
if (!budget) {
return [];
}
const transactions = await db
.select()
.from(expenses)
.where(
and(
eq(expenses.userId, session.user.id),
eq(expenses.budgetId, budget.id)
)
)
.orderBy(desc(expenses.date));
return transactions;
}
export async function createTransaction(data: {
category: "needs" | "wants" | "savings";
amount: number;
description: string;
}) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
throw new Error("Unauthorized");
}
const budget = await getBudget();
if (!budget) {
throw new Error("Buat budget dulu sebelum menambah transaksi");
}
const [transaction] = await db
.insert(expenses)
.values({
budgetId: budget.id,
userId: session.user.id,
category: data.category,
amount: data.amount.toString(),
description: data.description,
})
.returning();
revalidatePath("/");
return transaction;
}
export async function deleteTransaction(id: string) {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
throw new Error("Unauthorized");
}
await db
.delete(expenses)
.where(
and(
eq(expenses.id, id),
eq(expenses.userId, session.user.id)
)
);
revalidatePath("/");
}
Function createBudget handle pembuatan budget baru dengan kalkulasi otomatis alokasi 50-30-20. Method .returning() dari Drizzle ngembaliin data yang baru diinsert, jadi kita bisa dapet ID dan field lainnya.
Function getBudget ambil budget terbaru dari user yang lagi login. Return value-nya bisa null kalau user belum bikin budget, ini penting buat handling di UI nanti.
Function getTransactions ngambil semua transaksi expense dari user. Query-nya pake and() dari Drizzle buat nge-combine dua kondisi supaya user nggak bisa liat transaksi user lain.
Function createTransaction handle penambahan transaksi baru. Sebelum insert, kita cek dulu apakah user udah punya budget. Better fail fast dengan error message yang descriptive.
Function deleteTransaction buat hapus transaksi dengan double check: transaksi harus punya ID yang benar DAN milik user yang lagi login. Ini adalah defense in depth security practice.
Directive 'use server' di paling atas file nandain bahwa semua exported function adalah Server Actions yang cuma bisa jalan di server. Function-function ini nggak bakal masuk ke client-side bundle.
Kita pake revalidatePath("/") di setiap mutating action buat invalidate cache Next.js. Jadi pas user balik ke dashboard, data yang muncul udah yang terbaru tanpa perlu refresh manual.
Dashboard Layout
Sekarang waktunya bikin halaman dashboard yang jadi home base aplikasi kita. Dashboard ini bakal nampilin overview budget, form buat input penghasilan, dan list semua transaksi user.
Install komponen yang dibutuhkan
npx shadcn@latest add progress badge dialog
Buat dashboard page
Bikin file app/(dashboard)/page.tsx:
import { getBudget, getTransactions, createBudget } from "@/app/actions/budget";
import { BudgetInputForm } from "@/components/budget/BudgetInputForm";
import { BudgetOverview } from "@/components/budget/BudgetOverview";
import { TransactionList } from "@/components/budget/TransactionList";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth.api.getSession({
headers: await headers(),
});
if (!session?.user) {
redirect("/signin");
}
const budget = await getBudget();
const transactions = await getTransactions();
const handleCreateBudget = async (data: {
monthlyIncome: number;
needsAmount: number;
wantsAmount: number;
savingsAmount: number;
}) => {
"use server";
await createBudget({ monthlyIncome: data.monthlyIncome });
};
return (
<div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
<div className="container mx-auto px-4 py-8 max-w-7xl">
<div className="mb-8">
<h1 className="text-4xl font-bold tracking-tight mb-2">
Halo, {session.user.name}! 👋
</h1>
<p className="text-muted-foreground text-lg">
Kelola keuangan kamu dengan metode 50-30-20
</p>
</div>
{!budget ? (
<Card className="border-border/40 shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Mulai Budget Kamu</CardTitle>
<CardDescription>
Masukkan penghasilan bulanan untuk memulai tracking budget
</CardDescription>
</CardHeader>
<CardContent>
<BudgetInputForm onSubmit={handleCreateBudget} />
</CardContent>
</Card>
) : (
<div className="space-y-6">
<BudgetOverview budget={budget} transactions={transactions} />
<TransactionList transactions={transactions} />
</div>
)}
</div>
</div>
);
}

Penjelasan Dashboard Page
Page ini adalah Server Component yang fetch data langsung di server sebelum render. Pertama kita check session user, kalau belum login langsung redirect ke signin page.
Terus kita fetch budget dan transactions pake Server Actions yang udah kita bikin di Part 6.1. Karena ini Server Component, fetch-nya jalan di server jadi lebih cepet dan SEO-friendly.
Conditional Rendering
Conditional rendering di sini penting banget: kalau user belum bikin budget (!budget), kita tampilin Card dengan form BudgetInputForm buat setup budget pertama kali.
Kalau udah ada budget, baru kita tampilin komponen BudgetOverview dan TransactionList yang bakal kita bikin di section berikutnya.
Component BudgetInputForm
Form BudgetInputForm yang kita pake di sini udah dibuat di Part 5 sebelumnya. Component ini handle input penghasilan bulanan dan auto-calculate pembagian 50-30-20.
Kita perlu passing props onSubmit ke BudgetInputForm dengan function handleCreateBudget yang memanggil Server Action createBudget. Function ini dibuat sebagai inline Server Action dengan directive "use server".
Handling Budget Creation
Function handleCreateBudget menerima data yang udah di-calculate dari BudgetInputForm (monthlyIncome, needsAmount, wantsAmount, savingsAmount). Tapi kita cuma perlu passing monthlyIncome ke Server Action karena calculation 50-30-20 udah di-handle di server.
Layout Structure
Layout pake container dengan max-w-7xl supaya content nggak terlalu lebar di layar besar. Background gradient subtle dari from-background ke to-primary/5 kasih depth tanpa overwhelming.
Header section dengan greeting personalized pake nama user dari session. Ini adalah small touch tapi bikin aplikasi terasa lebih personal dan welcoming.
Responsive Design
Grid dan spacing pake Tailwind utilities yang responsive. Padding px-4 dan py-8 kasih breathing room yang cukup. Card component dari shadcn udah responsive by default.
Dashboard layout udah siap! Sekarang kita punya structure yang clean dengan proper data fetching dan conditional rendering based on user state. Next kita bakal bikin Budget Overview Section buat nampilin breakdown budget dan spending progress.
Budget Overview Section
Section ini adalah jantung dari dashboard kita. Di sini user bakal liat berapa budget yang udah kepake, sisa budget, dan visual representation dari spending mereka menggunakan komponen CategoryCard yang udah kita bikin di Part 5.
Bikin Budget Overview Component
Buat file components/budget/BudgetOverview.tsx:
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CategoryCard } from "./CategoryCard";
interface BudgetOverviewProps {
budget: {
monthlyIncome: string;
needsAmount: string;
wantsAmount: string;
savingsAmount: string;
};
transactions: Array<{
category: string;
amount: string;
}>;
}
export function BudgetOverview({ budget, transactions }: BudgetOverviewProps) {
const formatCurrency = (value: string | number) => {
const num = typeof value === "string" ? parseFloat(value) : value;
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(num);
};
const calculateSpent = (category: string) => {
return transactions
.filter((t) => t.category === category)
.reduce((sum, t) => sum + parseFloat(t.amount), 0);
};
const needsSpent = calculateSpent("needs");
const wantsSpent = calculateSpent("wants");
const savingsSpent = calculateSpent("savings");
const monthlyIncome = parseFloat(budget.monthlyIncome);
const needsBudget = parseFloat(budget.needsAmount);
const wantsBudget = parseFloat(budget.wantsAmount);
const savingsBudget = parseFloat(budget.savingsAmount);
const totalSpent = needsSpent + wantsSpent + savingsSpent;
const totalRemaining = monthlyIncome - totalSpent;
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<Card className="border-border/40 shadow-lg">
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Penghasilan Bulanan
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">
{formatCurrency(monthlyIncome)}
</div>
</CardContent>
</Card>
<Card className="border-border/40 shadow-lg">
<CardHeader>
<CardTitle className="text-sm font-medium text-muted-foreground">
Total Pengeluaran
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold">{formatCurrency(totalSpent)}</div>
<p className="text-sm text-muted-foreground mt-1">
Sisa: {formatCurrency(totalRemaining)}
</p>
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-3">
<CategoryCard
category="needs"
allocated={needsBudget}
spent={needsSpent}
/>
<CategoryCard
category="wants"
allocated={wantsBudget}
spent={wantsSpent}
/>
<CategoryCard
category="savings"
allocated={savingsBudget}
spent={savingsSpent}
/>
</div>
</div>
);
}

Penjelasan Budget Overview
Component ini menggunakan CategoryCard yang udah kita bikin di Part 5.3. CategoryCard udah include CircularGauge, badge status, dan semua informasi yang dibutuhin buat nampilin progress budget.
Currency Formatting - Function formatCurrency pake Intl.NumberFormat API buat format angka jadi format rupiah yang proper dengan separator ribuan titik dan prefix "Rp".
Calculate Spent - Function calculateSpent filter transactions by category terus sum semua amount-nya pake method reduce. Ini adalah logic penting buat tau berapa yang udah kepake di setiap kategori.
Summary Cards - Di bagian atas ada dua card yang nampilin big numbers: total income dan total spending dengan sisa budget. Ini kasih overview quick yang immediately visible.
CategoryCard Integration - Kita pake komponen CategoryCard yang udah dibuat sebelumnya buat nampilin detail setiap kategori. Tinggal pass props: category type, allocated amount, dan spent amount saja.
CategoryCard udah punya logic internal buat nentuin label, icon, dan color scheme berdasarkan category type. Jadi kita nggak perlu pass props tambahan, cukup 3 props aja dan component handle sisanya otomatis.
Grid Layout - Layout pake grid system yang responsive. md:grid-cols-2 buat summary cards, dan md:grid-cols-3 buat category cards. Di mobile jadi single column stacked.
Icon Per Kategori - CategoryCard udah punya config internal buat icon setiap kategori: 🏠 buat kebutuhan (needs), 🎮 buat keinginan (wants), 💰 buat tabungan (savings). Kita nggak perlu pass icon dari luar karena udah diatur di dalam component.
Budget overview udah complete dengan menggunakan komponen yang udah kita bikin sebelumnya! Nggak ada duplikasi code, semua modular dan reusable. Next kita bakal bikin Transaction List buat nampilin detailed history spending.
Transaction List Component
Section terakhir dari dashboard adalah transaction list yang nampilin semua pengeluaran user dalam format tabel yang rapi dengan fitur add transaction, delete, dan empty state.
Install komponen
npx shadcn@latest add table select
Bikin Transaction List Component
Buat file components/budget/TransactionList.tsx:
"use client";
import { useState } from "react";
import { createTransaction, deleteTransaction } from "@/app/actions/budget";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Trash2, Plus } from "lucide-react";
import { format } from "date-fns";
import { id as localeId } from "date-fns/locale";
interface Transaction {
id: string;
category: string;
amount: string;
description: string;
date: Date;
}
interface TransactionListProps {
transactions: Transaction[];
}
export function TransactionList({ transactions }: TransactionListProps) {
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [deletingId, setDeletingId] = useState<string | null>(null);
const formatCurrency = (value: string) => {
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
minimumFractionDigits: 0,
}).format(parseFloat(value));
};
const getCategoryBadge = (category: string) => {
const badges = {
needs: { label: "Kebutuhan", className: "bg-blue-100 text-blue-700" },
wants: { label: "Keinginan", className: "bg-purple-100 text-purple-700" },
savings: { label: "Tabungan", className: "bg-green-100 text-green-700" },
};
return badges[category as keyof typeof badges];
};
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setIsLoading(true);
setError("");
const formData = new FormData(e.currentTarget);
const category = formData.get("category") as "needs" | "wants" | "savings";
const amount = parseFloat(formData.get("amount") as string);
const description = formData.get("description") as string;
if (!category || isNaN(amount) || amount <= 0 || !description) {
setError("Mohon lengkapi semua field dengan benar");
setIsLoading(false);
return;
}
try {
await createTransaction({ category, amount, description });
setIsOpen(false);
(e.target as HTMLFormElement).reset();
} catch (err: any) {
setError(err?.message || "Terjadi kesalahan. Coba lagi.");
} finally {
setIsLoading(false);
}
};
const handleDelete = async (id: string) => {
if (!confirm("Yakin ingin menghapus transaksi ini?")) {
return;
}
setDeletingId(id);
try {
await deleteTransaction(id);
} catch (err: any) {
alert(err?.message || "Gagal menghapus transaksi");
} finally {
setDeletingId(null);
}
};
return (
<Card className="border-border/40 shadow-lg">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-2xl">Riwayat Transaksi</CardTitle>
<CardDescription>
Semua pengeluaran kamu tercatat di sini
</CardDescription>
</div>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Tambah Transaksi
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Tambah Transaksi Baru</DialogTitle>
<DialogDescription>
Catat pengeluaran kamu untuk tracking budget
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="category">Kategori</Label>
<Select name="category" required>
<SelectTrigger>
<SelectValue placeholder="Pilih kategori" />
</SelectTrigger>
<SelectContent>
<SelectItem value="needs">Kebutuhan (50%)</SelectItem>
<SelectItem value="wants">Keinginan (30%)</SelectItem>
<SelectItem value="savings">Tabungan (20%)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="amount">Jumlah</Label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground">
Rp
</span>
<Input
id="amount"
name="amount"
type="number"
placeholder="50000"
className="pl-9"
disabled={isLoading}
step="1000"
min="0"
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Deskripsi</Label>
<Input
id="description"
name="description"
type="text"
placeholder="Contoh: Beli groceries di Indomaret"
disabled={isLoading}
required
/>
</div>
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setIsOpen(false)}
disabled={isLoading}
className="flex-1"
>
Batal
</Button>
<Button type="submit" disabled={isLoading} className="flex-1">
{isLoading ? "Menyimpan..." : "Simpan"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent>
{transactions.length === 0 ? (
<div className="text-center py-12">
<div className="text-muted-foreground mb-4">
<svg
className="mx-auto h-12 w-12 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"
/>
</svg>
</div>
<h3 className="text-lg font-medium mb-1">
Belum ada transaksi
</h3>
<p className="text-sm text-muted-foreground mb-4">
Mulai catat pengeluaran kamu untuk tracking budget
</p>
<Button onClick={() => setIsOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
Tambah Transaksi Pertama
</Button>
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tanggal</TableHead>
<TableHead>Deskripsi</TableHead>
<TableHead>Kategori</TableHead>
<TableHead className="text-right">Jumlah</TableHead>
<TableHead className="text-right">Aksi</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.map((transaction) => {
const badge = getCategoryBadge(transaction.category);
return (
<TableRow key={transaction.id}>
<TableCell className="font-medium">
{format(new Date(transaction.date), "dd MMM yyyy", {
locale: localeId,
})}
</TableCell>
<TableCell>{transaction.description}</TableCell>
<TableCell>
<Badge className={badge.className}>
{badge.label}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{formatCurrency(transaction.amount)}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(transaction.id)}
disabled={deletingId === transaction.id}
>
<Trash2 className="h-4 w-4 text-red-600" />
</Button>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}

Dialog untuk Add Transaction - Dialog dari shadcn/ui kita pake buat modal form add transaction. State isOpen control visibility dialog dan di-trigger dari button atau setelah successful submission.
Form Transaction - Form di dalam dialog punya tiga field: category (dropdown dengan Select component), amount (number input dengan prefix Rp), dan description. Setiap option di Select dikasih label descriptive kayak "Kebutuhan (50%)".
Validation dan Error Handling - Client-side validation check semua field required dan amount harus valid number. Try-catch block handle server errors, success case close dialog dan reset form supaya clean buat transaction berikutnya.
Empty State - Kalo transactions empty, tampilin empty state yang engaging dengan icon clipboard, heading, description, sama CTA button. Empty state ini actively encourage user buat take action.
Table Structure - Table pake semantic HTML elements dari shadcn/ui buat accessibility. Columns diatur logically: Date, Description, Category, Amount, Actions. Order ini match natural reading flow.
Date Formatting - Package date-fns dengan locale Indonesia format tanggal jadi "dd MMM yyyy" yang familiar buat user Indonesia kayak "16 Okt 2025".
Category Badge - Function getCategoryBadge return configuration object dengan label dan className buat setiap kategori. Badge colors consistent sama yang dipake di Budget Overview.
Delete Functionality - Delete button pake icon Trash2 dari lucide-react dengan warna merah buat indicate destructive action. Confirmation dialog native browser prevent accidental deletion. State deletingId track button mana yang lagi process deletion.
Dashboard page udah complete sekarang! User bisa input budget, track spending per category, add transactions, dan delete transaction. Interface-nya intuitive dengan visual feedback yang jelas. Happy coding dengan BuildWithAngga Budget Tracker! 🚀
Penutup
Artikel ini memberikan panduan lengkap untuk membuat aplikasi budget tracker berbasis metode 50-30-20. Dari setup awal project Next.js 15 hingga implementasi authentication dengan Better Auth, database PostgreSQL via Supabase, hingga komponen-komponen UI yang interaktif—semuanya dirancang untuk pemula.
Poin Kunci:
Kamu akan memahami arsitektur aplikasi modern dengan Next.js 15, penerapan sistem autentikasi yang aman, database management dengan Drizzle ORM, serta cara membangun UI yang responsive dan user-friendly menggunakan Tailwind CSS dan shadcn/ui. Aplikasi ini bukan hanya pembelajaran teori, melainkan praktik langsung membangun aplikasi finansial yang fungsional.
Saran Lanjutan:
Untuk menguasai topik ini secara mendalam dan menggali lebih dalam tentang best practices development modern, saya rekomendasikan mengikuti kelas di BuildWithAngga (BWA). Disana kamu akan mendapat tutorial video terstruktur yang tidak hanya menjelaskan what dan how, tetapi juga why di balik setiap keputusan teknis. Dengan belajar di BWA, kamu akan mendapat bimbingan dari praktisi berpengalaman, akses ke project lengkap, dan komunitas yang siap membantu ketika stuck. Ini jauh lebih efektif daripada belajar piece-by-piece dari berbagai sumber.
Mulai perjalanan development kamu dengan fondasi yang kuat—investasi kecil sekarang akan membuat perbedaan besar di karir tech kamu.