Membuat CMS Blog Personal dengan Next.js, Better Auth, dan Supabase - Tutorial Lengkap untuk Pemula

Project Setup

Halo teman-teman! Di chapter pertama ini kita akan memulai perjalanan membuat CMS blog personal yang keren banget. Kita bakal pakai teknologi yang lagi hot banget di dunia web development, yaitu Next.js 15 dengan fitur terbaru yang bikin development jadi lebih mudah dan cepat.

Sebelum kita mulai coding, pastikan kamu sudah punya Node.js versi 18 ke atas ya. Kalau belum punya, download dulu di website resminya. Nah, untuk chapter ini kita fokus dulu ke setup project yang solid, karena fondasi yang kuat itu penting banget untuk project jangka panjang.

Setup Next.js 15 dengan App Router

Oke, mari kita mulai dengan setup Next.js 15. Yang menarik dari versi terbaru ini adalah adanya opsi BiomeJS sebagai linter dan formatter, yang performanya jauh lebih cepat dibanding ESLint + Prettier tradisional.

Buka PowerShell di Windows (bukan Command Prompt biasa ya), lalu jalankan command ini:

npx create-next-app@latest bwa-blog

Nanti kamu akan diminta beberapa pilihan konfigurasi. Pastikan pilih seperti ini:

Terminal: Setup Next.js Project
Terminal: Setup Next.js Project

Yang paling penting disini adalah memilih BiomeJS. Ini adalah linter dan formatter yang dikembangkan oleh team Rome, dan performanya luar biasa cepat. BiomeJS bisa menggantikan ESLint dan Prettier sekaligus dengan konfigurasi yang lebih simpel.

Setelah instalasi selesai, masuk ke folder project:

cd bwa-blog

Coba jalankan development server untuk memastikan semuanya berjalan dengan baik:

npm run dev

Tampilan Awal Next.js
Tampilan Awal Next.js

Buka browser dan akses http://localhost:3000. Kalau kamu melihat halaman welcome Next.js, berarti setup awal sudah berhasil!

Integrasi shadcn/ui untuk Component Library

Sekarang kita akan install shadcn/ui, yang merupakan component library yang sangat populer di komunitas React. Library ini memberikan komponen-komponen yang sudah ter-styling dengan Tailwind CSS dan fully customizable.

Pertama, kita perlu install shadcn/ui CLI:

npx shadcn@latest init

Saat ditanya konfigurasi, pilih seperti ini:

Terminal: Setup shadcn ui
Terminal: Setup shadcn ui

Setelah itu, mari kita install beberapa komponen dasar yang pasti akan kita butuhkan untuk CMS blog:

npx shadcn@latest add button card input label textarea
npx shadcn@latest add navigation-menu breadcrumb separator
npx shadcn@latest add dialog alert-dialog dropdown-menu

bwa-blog - Visual Studio Code 23_09_2025 11_20_44.png
shadcn components

Komponen-komponen ini akan menjadi building blocks utama untuk interface CMS kita nanti. Button untuk berbagai aksi, Card untuk container content, Input dan Textarea untuk form, Navigation untuk menu, Dialog untuk modal, dan Alert Dialog untuk konfirmasi.

Struktur Folder yang Rapi dan Scalable

Struktur folder yang baik itu kayak pondasi rumah. Kalau dari awal sudah rapi, nanti development jadi lebih smooth dan mudah di-maintain. Untuk project CMS blog ini, kita akan pakai struktur yang udah terbukti di berbagai project BuildWithAngga.

Buat struktur folder seperti ini di dalam folder src/:

src/
├── app/
│   ├── (auth)/
│   │   ├── login/
│   │   └── register/
│   ├── (dashboard)/
│   │   ├── dashboard/
│   │   ├── posts/
│   │   └── settings/
│   ├── api/
│   │   └── auth/
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── components/
│   ├── ui/ (dari shadcn)
│   ├── auth/
│   ├── blog/
│   ├── dashboard/
│   └── shared/
├── lib/
│   ├── auth/
│   ├── database/
│   └── utils.ts
├── types/
│   ├── auth.ts
│   ├── blog.ts
│   └── database.ts
└── hooks/
    ├── use-auth.ts
    └── use-posts.ts

Mari kita bahas satu per satu:

Folder app/ - Ini adalah heart dari App Router Next.js 15. Kita pakai route groups dengan tanda kurung untuk organisasi yang lebih baik. (auth) untuk halaman authentication, (dashboard) untuk area admin CMS.

Folder components/ - Tempat semua UI components kita. ui/ sudah otomatis dari shadcn, sisanya kita kategorikan berdasarkan fungsi. shared/ untuk komponen yang dipakai di banyak tempat.

Folder lib/ - Library dan utility functions. auth/ untuk logic authentication, database/ untuk koneksi dan query database, utils.ts untuk helper functions umum.

Folder types/ - TypeScript type definitions. Pisah berdasarkan domain supaya mudah di-maintain.

Folder hooks/ - Custom React hooks untuk logic yang reusable.

Sekarang mari buat beberapa file dasar. Pertama, buat file src/lib/utils.ts yang sudah dibutuhkan oleh shadcn:

import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

export function formatDate(date: Date): string {
  return new Intl.DateTimeFormat("id-ID", {
    day: "numeric",
    month: "long",
    year: "numeric",
  }).format(date);
}

export function slugify(text: string): string {
  return text
    .toLowerCase()
    .replace(/ /g, "-")
    .replace(/[^a-z0-9-]/g, "")
    .replace(/-+/g, "-")
    .trim();
}

Lalu buat file src/types/blog.ts untuk type definitions blog:

export interface BlogPost {
  id: string
  title: string
  slug: string
  content: string
  excerpt: string
  featuredImage?: string
  publishedAt: Date | null
  createdAt: Date
  updatedAt: Date
  authorId: string
  published: boolean
  tags: string[]
}

export interface BlogTag {
  id: string
  name: string
  slug: string
  description?: string
  postCount: number
}

export interface CreatePostData {
  title: string
  content: string
  excerpt: string
  featuredImage?: string
  published: boolean
  tags: string[]
}

Terakhir, mari kita update src/app/layout.tsx dengan konfigurasi yang lebih baik:

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "BWA Blog CMS",
  description:
    "Content Management System untuk blog personal yang powerful dan mudah digunakan",
  keywords: ["blog", "cms", "next.js", "buildwithangga"],
  authors: [{ name: "BuildWithAngga Team" }],
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="id">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

Nah, sampai sini kita sudah punya fondasi project yang solid. Next.js 15 dengan App Router untuk routing yang modern, BiomeJS untuk linting yang cepat, shadcn/ui untuk component library yang customizable, dan struktur folder yang rapi untuk maintainability jangka panjang.

Di chapter selanjutnya, kita akan mulai setup database dengan Supabase dan konfigurasi authentication dengan Better Auth. Pastikan kamu sudah comfortable dengan setup ini sebelum lanjut ya, karena semua chapter berikutnya akan build on top of foundation yang udah kita buat hari ini.

Database Schema

Nah, setelah project setup udah selesai, sekarang waktunya kita bikin database yang solid untuk blog CMS kita. Di chapter ini kita akan setup Supabase sebagai database utama dan Drizzle ORM untuk manajemen schema yang lebih mudah dan type-safe. Kombinasi ini udah terbukti powerful banget di banyak project BuildWithAngga.

Database schema yang baik itu ibarat blueprint rumah yang kuat. Kalau dari awal udah didesain dengan benar, nanti waktu develop fitur-fitur kompleks jadi jauh lebih mudah. Kita akan pakai struktur database yang simple tapi fleksible untuk kebutuhan blog personal.

Setup Supabase Project

Pertama-tama, kita perlu bikin project di Supabase. Supabase itu basically PostgreSQL yang sudah dikemas dengan berbagai fitur tambahan kayak authentication, real-time subscriptions, dan REST API yang otomatis ter-generate. Perfect banget untuk project seperti ini.

Buka website supabase.com dan buat akun baru kalau belum punya. Setelah login, klik "New Project" dan isi detail project:

Project Name: BWA Blog
Database Password: [password yang kuat, simpan baik-baik]
Region: Southeast Asia (Singapore)

Supabase Project
Supabase Project

Proses pembuatan project biasanya butuh 2-3 menit. Sambil nunggu, kita bisa install dependencies yang diperlukan di project Next.js kita.

Buka terminal di folder project bwa-blog dan install Drizzle ORM beserta dependencies lainnya:

npm install drizzle-orm postgres
npm install -D drizzle-kit

Drizzle ORM ini pilihan yang sangat bagus karena fully type-safe, performancenya excellent, dan migration systemnya yang reliable. Plus, dokumentasinya juga sangat lengkap untuk pemula.

Setelah project Supabase selesai dibuat, copy connection string dari dashboard. Masuk ke Settings > Database, lalu scroll ke bagian "Connection String". Pilih yang "URI" format.

Buat file .env.local di root project dan tambahkan environment variables:

DATABASE_URL=postgresql://postgres:[password]@[host]:[port]/postgres
DIRECT_URL=postgresql://postgres:[password]@[host]:[port]/postgres?sslmode=require
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key

Konfigurasi Drizzle ORM

Sekarang mari kita setup Drizzle configuration. Buat file drizzle.config.ts di root project:

import type { Config } from "drizzle-kit";

export default {
  schema: "./src/db/schema.ts",
  out: "./src/db/migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.DIRECT_URL as string,
  },
  verbose: true,
  strict: true,
} satisfies Config;

Konfigurasi ini ngasih tau Drizzle dimana file schema kita, kemana migration files disimpan, dan koneksi database yang digunakan. Property verbose berguna untuk debugging, sedangkan strict memastikan migration berjalan dengan aman.

Selanjutnya, buat file src/db/index.ts untuk database connection:

import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

if (!process.env.DATABASE_URL) {
  throw new Error("DATABASE_URL is not defined");
}

const client = postgres(process.env.DATABASE_URL, {
  prepare: false,
});

export const db = drizzle(client, { schema });

Membuat Schema Database untuk Blog

Nah, sekarang bagian yang seru. Kita akan definisikan schema lengkap untuk blog CMS. Buat file src/db/schema.ts:

import { relations } from "drizzle-orm";
import {
  integer,
  pgTable,
  primaryKey,
  text,
  timestamp,
  uuid,
} from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod";

// Export auth schema
export * from './auth-schema'

// Posts table - tabel utama untuk artikel blog
export const posts = pgTable("posts", {
  id: uuid("id").primaryKey().defaultRandom(),
  title: text("title").notNull(),
  slug: text("slug").notNull().unique(),
  content: text("content").notNull(),
  excerpt: text("excerpt"),
  featuredImage: text("featured_image"),
  status: text("status", { enum: ["draft", "published", "archived"] }).default(
    "draft",
  ),
  publishedAt: timestamp("published_at"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
  authorId: text("author_id").notNull(),
  categoryId: uuid("category_id"),
}).enableRLS();

// Categories table - untuk mengelompokkan artikel
export const categories = pgTable("categories", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
}).enableRLS();

// Tags table - untuk labeling artikel yang lebih fleksibel
export const tags = pgTable("tags", {
  id: uuid("id").primaryKey().defaultRandom(),
  name: text("name").notNull(),
  slug: text("slug").notNull().unique(),
  description: text("description"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}).enableRLS();

// Junction table untuk many-to-many relationship posts dan tags
export const postTags = pgTable(
  "post_tags",
  {
    postId: uuid("post_id")
      .notNull()
      .references(() => posts.id, { onDelete: "cascade" }),
    tagId: uuid("tag_id")
      .notNull()
      .references(() => tags.id, { onDelete: "cascade" }),
  },
  (table) => [
    primaryKey({ columns: [table.postId, table.tagId] }),
  ],
).enableRLS();

// Comments table - untuk sistem komentar
export const comments = pgTable("comments", {
  id: uuid("id").primaryKey().defaultRandom(),
  content: text("content").notNull(),
  postId: uuid("post_id")
    .notNull()
    .references(() => posts.id, { onDelete: "cascade" }),
  authorName: text("author_name").notNull(),
  authorEmail: text("author_email").notNull(),
  status: text("status", { enum: ["pending", "approved", "rejected"] }).default(
    "pending",
  ),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}).enableRLS();

// Media table - untuk mengelola file upload
export const media = pgTable("media", {
  id: uuid("id").primaryKey().defaultRandom(),
  filename: text("filename").notNull(),
  originalName: text("original_name").notNull(),
  mimeType: text("mime_type").notNull(),
  size: integer("size").notNull(),
  url: text("url").notNull(),
  altText: text("alt_text"),
  createdAt: timestamp("created_at").defaultNow().notNull(),
  uploadedBy: text("uploaded_by").notNull(),
}).enableRLS();

// Relations untuk memudahkan query dengan join
export const postsRelations = relations(posts, ({ one, many }) => ({
  category: one(categories, {
    fields: [posts.categoryId],
    references: [categories.id],
  }),
  comments: many(comments),
  postTags: many(postTags),
}));

export const categoriesRelations = relations(categories, ({ many }) => ({
  posts: many(posts),
}));

export const tagsRelations = relations(tags, ({ many }) => ({
  postTags: many(postTags),
}));

export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, {
    fields: [postTags.postId],
    references: [posts.id],
  }),
  tag: one(tags, {
    fields: [postTags.tagId],
    references: [tags.id],
  }),
}));

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
}));

// Zod schemas untuk validasi
export const insertPostSchema = createInsertSchema(posts);
export const selectPostSchema = createSelectSchema(posts);
export const insertCategorySchema = createInsertSchema(categories);
export const selectCategorySchema = createSelectSchema(categories);
export const insertTagSchema = createInsertSchema(tags);
export const selectTagSchema = createSelectSchema(tags);
export const insertCommentSchema = createInsertSchema(comments);
export const selectCommentSchema = createSelectSchema(comments);
export const insertMediaSchema = createInsertSchema(media);
export const selectMediaSchema = createSelectSchema(media);

Schema ini udah dirancang untuk mengcover semua kebutuhan blog personal yang lengkap. Tabel posts sebagai core entity, categories untuk pengelompokan, tags untuk labeling yang fleksibel, comments untuk interaksi pembaca, dan media untuk file management.

Yang menarik dari Drizzle adalah kita bisa define relations dengan mudah, dan otomatis generate Zod schemas untuk validasi input. Ini bikin development jadi lebih cepat dan aman.

Generate dan Run Migration

Sekarang kita perlu generate migration file dari schema yang sudah kita buat. Tambahkan scripts ini ke package.json:

{
  "scripts": {
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  }
}

Generate migration pertama:

npm run db:generate

Command ini akan membuat file migration di folder src/db/migrations. File migration ini berisi SQL statements yang diperlukan untuk membuat tabel-tabel sesuai schema kita.

Sebagai bonus, Drizzle Kit punya fitur Drizzle Studio yang berguna banget untuk exploring database. Jalankan:

npm run db:studio

Buka browser di http://localhost:4983 dan kamu bisa lihat semua tabel dengan interface yang user-friendly. Perfect untuk debugging dan testing query nanti.

Schema database yang udah kita buat ini foundasi yang solid untuk CMS blog. Di chapter selanjutnya, kita akan integrate Better Auth untuk sistem authentication yang secure dan user-friendly. Database schema ini udah compatible dengan Better Auth, jadi integration nanti akan smooth banget.

Better Auth Setup

Nah, sekarang kita masuk ke bagian yang seru nih, yaitu setup authentication untuk blog CMS kita. Di chapter ini kita akan implement Better Auth yang merupakan salah satu authentication library terbaru dan paling comprehensive untuk TypeScript. Yang bikin Better Auth menarik adalah flexibilitas dan plugin ecosystem yang sangat lengkap.

Kita akan setup multi-user system dengan role-based access control yang memungkinkan admin manage users, authors bisa buat konten, dan users bisa comment. Ini approach yang lebih real-world dan valuable untuk portfolio project. Better Auth dengan username plugin juga akan memberikan pengalaman login yang lebih user-friendly dibanding pakai email.

Instalasi dan Konfigurasi Better Auth

Pertama-tama, kita install Better Auth beserta dependencies yang diperlukan. Buka terminal di project bwa-blog dan jalankan command berikut:

npm install better-auth

Selanjutnya, kita buat konfigurasi Better Auth. Buat file src/lib/auth.ts:

import { betterAuth } from 'better-auth'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { username } from 'better-auth/plugins'
import { db } from '@/db'
import * as schema from '@/db/schema'

export const auth = betterAuth({
  database: drizzleAdapter(db, {
    provider: 'pg',
    schema,
  }),
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: false, // Untuk kemudahan development
  },
  plugins: [
    username({
      minUsernameLength: 3,
      maxUsernameLength: 20,
      usernameValidator: (username) => {
        // Hanya allow alphanumeric dan underscore
        return /^[a-zA-Z0-9_]+$/.test(username)
      },
    }),
  ],
  session: {
    expiresIn: 60 * 60 * 24 * 7, // 7 hari
    updateAge: 60 * 60 * 24, // Update setiap 24 jam
  },
  user: {
    additionalFields: {
      role: {
        type: 'string',
        defaultValue: 'user', // Default role untuk new user
      },
    },
  },
  trustedOrigins: ['<http://localhost:3000>'],
})

export type Session = typeof auth.$Infer.Session

Konfigurasi ini udah disesuaikan untuk kebutuhan multi-user blog CMS. Username plugin akan memungkinkan login dengan username yang lebih mudah diingat, session 7 hari supaya ga perlu login terus-terusan, dan default role "user" untuk new registrants dengan sistem approval dari admin.

Setup API Route Handler

Better Auth butuh API route untuk handle semua authentication requests. Buat file src/app/api/auth/[...all]/route.ts:

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

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

Route ini akan handle semua authentication endpoints secara otomatis. Better Auth akan generate endpoints seperti /api/auth/sign-in, /api/auth/sign-up, /api/auth/sign-out, dan lain-lain.

Membuat Auth Client

Sekarang kita buat client untuk frontend. Buat file src/lib/auth-client.ts:

import { createAuthClient } from "better-auth/client";
import { usernameClient } from "better-auth/client/plugins";

export const authClient = createAuthClient({
  baseURL: process.env.NEXT_PUBLIC_APP_URL || "<http://localhost:3000>",
  plugins: [usernameClient()],
});

export const { signIn, signUp, signOut, useSession, getSession } = authClient;

Client ini akan kita pakai di components untuk handle authentication di frontend. Plugin usernameClient memungkinkan kita pakai method signIn.username selain signIn.email.

Update Environment Variables

Tambahkan environment variables yang diperlukan di .env.local:

DATABASE_URL=postgresql://postgres:[password]@[host]:[port]/postgres
DIRECT_URL=postgresql://postgres:[password]@[host]:[port]/postgres?sslmode=require
BETTER_AUTH_SECRET=your-secret-key-generate-with-openssl
BETTER_AUTH_URL=http://localhost:3000
NEXT_PUBLIC_APP_URL=http://localhost:3000

Untuk generate BETTER_AUTH_SECRET, kamu bisa pakai command:

openssl rand -base64 32

Secret ini penting banget untuk encryption dan hashing, jadi pastikan simpan dengan aman dan jangan commit ke git.

Sync Database Schema

Better Auth butuh beberapa tabel untuk user dan session management. Kita perlu update Drizzle schema untuk include tabel-tabel ini. Buat file src/db/auth-schema.ts untuk Better Auth tables:

import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core'
import { relations } from 'drizzle-orm'

// Better Auth tables dengan RLS enabled
export const user = pgTable('user', {
  id: text('id').primaryKey(),
  name: text('name').notNull(),
  email: text('email').notNull().unique(),
  emailVerified: boolean('emailVerified').notNull().default(false),
  image: text('image'),
  createdAt: timestamp('createdAt').notNull(),
  updatedAt: timestamp('updatedAt').notNull(),
  // Username plugin fields
  username: text('username').unique(),
  displayUsername: text('displayUsername'),
  // Additional fields for user roles
  role: text('role').notNull().default('user'),
}).enableRLS()

export const session = pgTable('session', {
  id: text('id').primaryKey(),
  expiresAt: timestamp('expiresAt').notNull(),
  token: text('token').notNull().unique(),
  createdAt: timestamp('createdAt').notNull(),
  updatedAt: timestamp('updatedAt').notNull(),
  ipAddress: text('ipAddress'),
  userAgent: text('userAgent'),
  userId: text('userId').notNull().references(() => user.id),
}).enableRLS()

export const account = pgTable('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').notNull(),
  updatedAt: timestamp('updatedAt').notNull(),
}).enableRLS()

export const verification = pgTable('verification', {
  id: text('id').primaryKey(),
  identifier: text('identifier').notNull(),
  value: text('value').notNull(),
  expiresAt: timestamp('expiresAt').notNull(),
  createdAt: timestamp('createdAt'),
  updatedAt: timestamp('updatedAt'),
}).enableRLS()

// Relations untuk Better Auth tables
export const userRelations = relations(user, ({ many }) => ({
  sessions: many(session),
  accounts: many(account),
}))

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

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

Lalu update file src/db/schema.ts dan tambahkan export untuk auth schema dengan RLS enabled untuk blog tables juga:

import { pgTable, uuid, text, timestamp, boolean, integer, primaryKey } from 'drizzle-orm/pg-core'
import { createInsertSchema, createSelectSchema } from 'drizzle-zod'
import { relations } from 'drizzle-orm'

// Export auth schema
export * from './auth-schema'

// Posts table dengan RLS - tabel utama untuk artikel blog
export const posts = pgTable('posts', {
  id: uuid('id').primaryKey().defaultRandom(),
  title: text('title').notNull(),
  slug: text('slug').notNull().unique(),
  content: text('content').notNull(),
  excerpt: text('excerpt'),
  featuredImage: text('featured_image'),
  status: text('status', { enum: ['draft', 'published', 'archived'] }).default('draft'),
  publishedAt: timestamp('published_at'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
  authorId: text('author_id').notNull(),
  categoryId: uuid('category_id'),
}).enableRLS()

// Categories table dengan RLS - untuk mengelompokkan artikel
export const categories = pgTable('categories', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  updatedAt: timestamp('updated_at').defaultNow().notNull(),
}).enableRLS()

// Tags table dengan RLS - untuk labeling artikel yang lebih fleksibel
export const tags = pgTable('tags', {
  id: uuid('id').primaryKey().defaultRandom(),
  name: text('name').notNull(),
  slug: text('slug').notNull().unique(),
  description: text('description'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}).enableRLS()

// Junction table untuk many-to-many relationship posts dan tags dengan RLS
export const postTags = pgTable('post_tags', {
  postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
  tagId: uuid('tag_id').notNull().references(() => tags.id, { onDelete: 'cascade' }),
}, (table) => ({
  pk: primaryKey({ columns: [table.postId, table.tagId] })
})).enableRLS()

// Comments table dengan RLS - untuk sistem komentar
export const comments = pgTable('comments', {
  id: uuid('id').primaryKey().defaultRandom(),
  content: text('content').notNull(),
  postId: uuid('post_id').notNull().references(() => posts.id, { onDelete: 'cascade' }),
  authorName: text('author_name').notNull(),
  authorEmail: text('author_email').notNull(),
  status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('pending'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
}).enableRLS()

// Media table dengan RLS - untuk mengelola file upload
export const media = pgTable('media', {
  id: uuid('id').primaryKey().defaultRandom(),
  filename: text('filename').notNull(),
  originalName: text('original_name').notNull(),
  mimeType: text('mime_type').notNull(),
  size: integer('size').notNull(),
  url: text('url').notNull(),
  altText: text('alt_text'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
  uploadedBy: text('uploaded_by').notNull(),
}).enableRLS()

// Relations untuk memudahkan query dengan join
export const postsRelations = relations(posts, ({ one, many }) => ({
  category: one(categories, {
    fields: [posts.categoryId],
    references: [categories.id],
  }),
  comments: many(comments),
  postTags: many(postTags),
}))

export const categoriesRelations = relations(categories, ({ many }) => ({
  posts: many(posts),
}))

export const tagsRelations = relations(tags, ({ many }) => ({
  postTags: many(postTags),
}))

export const postTagsRelations = relations(postTags, ({ one }) => ({
  post: one(posts, {
    fields: [postTags.postId],
    references: [posts.id],
  }),
  tag: one(tags, {
    fields: [postTags.tagId],
    references: [tags.id],
  }),
}))

export const commentsRelations = relations(comments, ({ one }) => ({
  post: one(posts, {
    fields: [comments.postId],
    references: [posts.id],
  }),
}))

// Zod schemas untuk validasi
export const insertPostSchema = createInsertSchema(posts)
export const selectPostSchema = createSelectSchema(posts)
export const insertCategorySchema = createInsertSchema(categories)
export const selectCategorySchema = createSelectSchema(categories)
export const insertTagSchema = createInsertSchema(tags)
export const selectTagSchema = createSelectSchema(tags)
export const insertCommentSchema = createInsertSchema(comments)
export const selectCommentSchema = createSelectSchema(comments)
export const insertMediaSchema = createInsertSchema(media)
export const selectMediaSchema = createSelectSchema(media)

Generate dan jalankan migration baru:

npm run db:generate
npm run db:migrate

Membuat Login dan Register Pages

Sekarang kita buat halaman login dan register. Pertama, buat layout untuk auth pages. Buat file src/app/(auth)/layout.tsx:

import { redirect } from "next/navigation";
import { getServerSession } from "@/lib/get-session";

export default async function AuthLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  // Cek apakah user sudah login
  const session = await getServerSession();

  if (session) {
    // Redirect berdasarkan role
    if (session.user.role === "admin" || session.user.role === "author") {
      redirect("/dashboard");
    } else {
      redirect("/"); // User biasa ke homepage
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-muted/20">
      <div className="max-w-md w-full space-y-8">{children}</div>
    </div>
  );
}

Buat halaman login di src/app/(auth)/login/page.tsx:

import { LoginForm } from '@/components/auth/login-form'

export default function LoginPage() {
  return <LoginForm />
}

Buat component LoginForm di src/components/auth/login-form.tsx:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signIn } from "@/lib/auth-client";

export function LoginForm() {
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    setError("");
    startTransition(async () => {
      const identifier = formData.get("identifier") as string;
      const password = formData.get("password") as string;

      try {
        // Login dengan username (Better Auth username plugin)
        const { error } = await signIn.username({
          username: identifier,
          password,
        });

        if (error) {
          setError(error.message ?? "Terjadi kesalahan, coba lagi.");
          return;
        }

        // Setelah login berhasil, redirect ke home dan biarkan middleware handle routing
        router.push("/");
      } catch {
        setError("Username atau password salah");
      }
    });
  }

  return (
    <Card className="shadow-lg bg-background/80 backdrop-blur-sm">
      <CardHeader className="pb-6">
        <CardTitle className="text-2xl font-bold text-center text-foreground">
          Masuk ke Akun Anda
        </CardTitle>
        <p className="text-lg text-muted-foreground text-center">
          Masukkan username dan password untuk melanjutkan
        </p>
      </CardHeader>
      <CardContent className="space-y-6">
        <form action={handleSubmit} className="space-y-4">
          <div className="space-y-2">
            <Label htmlFor="identifier" className="text-sm font-medium">
              Username
            </Label>
            <Input
              id="identifier"
              name="identifier"
              type="text"
              required
              placeholder="Masukkan username Anda"
              className="h-11 border-border/40 focus:border-primary transition-colors"
              disabled={isPending}
            />
          </div>
          <div className="space-y-2">
            <Label htmlFor="password" className="text-sm font-medium">
              Password
            </Label>
            <Input
              id="password"
              name="password"
              type="password"
              required
              placeholder="Masukkan password Anda"
              className="h-11 border-border/40 focus:border-primary transition-colors"
              disabled={isPending}
            />
          </div>

          {error && (
            <div className="p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
              {error}
            </div>
          )}

          <Button
            type="submit"
            disabled={isPending}
            className="w-full h-11 text-sm font-medium transition-all hover:shadow-md duration-300"
          >
            {isPending ? (
              <div className="flex items-center justify-center space-x-2">
                <div className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></div>
                <span>Memproses...</span>
              </div>
            ) : (
              "Masuk"
            )}
          </Button>
        </form>

        <div className="pt-4 border-t border-border/40">
          <p className="text-center text-sm text-muted-foreground">
            Belum punya akun?{" "}
            <Link
              href="/register"
              className="font-medium text-primary hover:text-primary/80 transition-colors"
            >
              Daftar sekarang
            </Link>
          </p>
        </div>
      </CardContent>
    </Card>
  );
}

Halaman Login
Halaman Login

Buat halaman register di src/app/(auth)/register/page.tsx:

import { RegisterForm } from '@/components/auth/register-form'

export default function RegisterPage() {
  return <RegisterForm />
}

Buat component RegisterForm di src/components/auth/register-form.tsx:

"use client";

import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { signUp } from "@/lib/auth-client";

export function RegisterForm() {
  const [isPending, startTransition] = useTransition();
  const [error, setError] = useState("");
  const router = useRouter();

  async function handleSubmit(formData: FormData) {
    setError("");
    startTransition(async () => {
      const name = formData.get("name") as string;
      const username = formData.get("username") as string;
      const email = formData.get("email") as string;
      const password = formData.get("password") as string;

      try {
        const { error } = await signUp.email({
          name,
          username,
          email,
          password,
        });

        if (error) {
          setError(error.message ?? "Terjadi kesalahan, coba lagi.");
          return;
        }

        // Setelah register berhasil, redirect ke home dan biarkan middleware handle routing
        router.push("/");
      } catch {
        setError("Terjadi kesalahan saat mendaftar");
      }
    });
  }

  return (
    <Card className="border-0 shadow-lg bg-background/80 backdrop-blur-sm">
      <CardHeader className="space-y-1 pb-6">
        <CardTitle className="text-xl font-bold text-center text-foreground">
          Buat Akun Baru
        </CardTitle>
        <p className="text-sm text-muted-foreground text-center">
          Lengkapi informasi di bawah untuk mendaftar
        </p>
      </CardHeader>
      <CardContent className="space-y-6">
        <form action={handleSubmit} className="space-y-4">
          <div className="grid grid-cols-1 gap-4">
            <div className="space-y-2">
              <Label htmlFor="name" className="text-sm font-medium">
                Nama Lengkap
              </Label>
              <Input
                id="name"
                name="name"
                type="text"
                required
                placeholder="Contoh: John Doe"
                className="h-11 border-border/40 focus:border-primary transition-colors"
                disabled={isPending}
              />
            </div>
            <div className="space-y-2">
              <Label htmlFor="username" className="text-sm font-medium">
                Username
              </Label>
              <Input
                id="username"
                name="username"
                type="text"
                required
                placeholder="Contoh: johndoe"
                className="h-11 border-border/40 focus:border-primary transition-colors"
                disabled={isPending}
              />
            </div>
          </div>

          <div className="space-y-2">
            <Label htmlFor="email" className="text-sm font-medium">
              Email
            </Label>
            <Input
              id="email"
              name="email"
              type="email"
              required
              placeholder="Contoh: [email protected]"
              className="h-11 border-border/40 focus:border-primary transition-colors"
              disabled={isPending}
            />
          </div>

          <div className="space-y-2">
            <Label htmlFor="password" className="text-sm font-medium">
              Password
            </Label>
            <Input
              id="password"
              name="password"
              type="password"
              required
              placeholder="Minimal 8 karakter"
              minLength={8}
              className="h-11 border-border/40 focus:border-primary transition-colors"
              disabled={isPending}
            />
          </div>

          {error && (
            <div className="p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
              {error}
            </div>
          )}

          <Button
            type="submit"
            disabled={isPending}
            className="w-full h-11 text-sm font-medium transition-all hover:shadow-md disabled:opacity-50 duration-300"
          >
            {isPending ? (
              <div className="flex items-center justify-center space-x-2">
                <div className="w-4 h-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin"></div>
                <span>Memproses...</span>
              </div>
            ) : (
              "Buat Akun"
            )}
          </Button>
        </form>

        <div className="pt-4 border-t border-border/40">
          <p className="text-center text-sm text-muted-foreground">
            Sudah punya akun?{" "}
            <Link
              href="/login"
              className="font-medium text-primary hover:text-primary/80 transition-colors"
            >
              Masuk di sini
            </Link>
          </p>
        </div>
      </CardContent>
    </Card>
  );
}

Halaman Register
Halaman Register

Implementasi Protected Routes

Sekarang kita buat middleware untuk protect dashboard routes. Buat file middleware.ts di root project:

import { getSessionCookie } from "better-auth/cookies";
import { type NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;
  const session = getSessionCookie(request);

  // Protected routes untuk admin dan author saja
  if (pathname.startsWith("/dashboard")) {
    if (!session) {
      return NextResponse.redirect(new URL("/login", request.url));
    }
  }

  if (pathname.startsWith("/login") || pathname.startsWith("/register")) {
    if (session) {
      return NextResponse.redirect(new URL("/dashboard", request.url));
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};

Buat Dashboard Layout dengan Logout

Terakhir, buat basic dashboard layout dengan fungsi logout. Buat file src/app/(dashboard)/layout.tsx:

import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { LogoutButton } from "@/components/auth/logout-button";
import { auth } from "@/lib/auth";

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

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

  // Hanya admin yang bisa akses dashboard
  if (session.user.role !== "admin") {
    redirect("/");
  }

  return (
    <div className="min-h-screen bg-gradient-to-br from-background via-muted/10 to-muted/20">
      <header className="sticky top-0 z-50 bg-background/80 backdrop-blur-md border-b border-border/40 shadow-sm">
        <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
          <div className="flex justify-between items-center py-4">
            <div className="flex items-center space-x-4">
              <div className="w-10 h-10 bg-primary/10 rounded-xl flex items-center justify-center">
                <div className="w-5 h-5 bg-primary rounded"></div>
              </div>
              <div>
                <h1 className="text-xl font-bold text-foreground">BWA Blog</h1>
                <p className="text-xs text-muted-foreground">Dashboard</p>
              </div>
            </div>

            <div className="flex items-center space-x-4">
              <div className="hidden md:flex items-center space-x-3 px-3 py-2 bg-muted/50 rounded-full">
                <div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center">
                  <span className="text-xs font-semibold text-primary">
                    {session.user.name?.charAt(0).toUpperCase()}
                  </span>
                </div>
                <div className="text-sm">
                  <p className="font-medium text-foreground">
                    {session.user.name}
                  </p>
                  <p className="text-xs text-muted-foreground capitalize">
                    {session.user.role}
                  </p>
                </div>
              </div>
              <LogoutButton />
            </div>
          </div>
        </div>
      </header>
      <main className="max-w-7xl mx-auto py-8 px-4 sm:px-6 lg:px-8">
        {children}
      </main>
    </div>
  );
}

Buat juga component LogoutButton di src/components/auth/logout-button.tsx:

'use client'

import { signOut } from '@/lib/auth-client'
import { Button } from '@/components/ui/button'
import { useRouter } from 'next/navigation'

export function LogoutButton() {
  const router = useRouter()

  async function handleLogout() {
    await signOut()
    router.push('/')
  }

  return (
    <Button
      variant="outline"
      size="sm"
      onClick={handleLogout}
      className="hover:bg-destructive hover:text-destructive-foreground transition-colors"
    >
      Logout
    </Button>
  )
}

Buat Dashboard Page

Terakhir, buat halaman utama dashboard. Buat file src/app/(dashboard)/dashboard/page.tsx:

import { Button } from "@/components/ui/button";

export default function DashboardPage() {
  return (
    <div className="space-y-8">
      <div className="flex flex-col space-y-2">
        <h2 className="text-3xl font-bold tracking-tight text-foreground">
          Selamat Datang Kembali
        </h2>
        <p className="text-muted-foreground">
          Berikut adalah ringkasan aktivitas blog Anda hari ini
        </p>
      </div>

      {/* Stats Grid */}
      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="group relative bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950/20 dark:to-blue-900/20 p-6 rounded-xl border border-blue-200/50 dark:border-blue-800/50 hover:shadow-lg transition-all duration-200">
          <div className="flex items-center justify-between">
            <div>
              <p className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-1">
                Total Posts
              </p>
              <p className="text-3xl font-bold text-blue-900 dark:text-blue-100">
                0
              </p>
              <p className="text-xs text-blue-600/70 dark:text-blue-400/70 mt-1">
                +0 dari bulan lalu
              </p>
            </div>
            <div className="w-12 h-12 bg-blue-200/50 dark:bg-blue-800/30 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform">
              <div className="w-6 h-6 bg-blue-500 rounded"></div>
            </div>
          </div>
        </div>

        <div className="group relative bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-950/20 dark:to-orange-900/20 p-6 rounded-xl border border-orange-200/50 dark:border-orange-800/50 hover:shadow-lg transition-all duration-200">
          <div className="flex items-center justify-between">
            <div>
              <p className="text-sm font-medium text-orange-700 dark:text-orange-300 mb-1">
                Draft Posts
              </p>
              <p className="text-3xl font-bold text-orange-900 dark:text-orange-100">
                0
              </p>
              <p className="text-xs text-orange-600/70 dark:text-orange-400/70 mt-1">
                Siap untuk dipublikasi
              </p>
            </div>
            <div className="w-12 h-12 bg-orange-200/50 dark:bg-orange-800/30 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform">
              <div className="w-6 h-6 bg-orange-500 rounded"></div>
            </div>
          </div>
        </div>

        <div className="group relative bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950/20 dark:to-green-900/20 p-6 rounded-xl border border-green-200/50 dark:border-green-800/50 hover:shadow-lg transition-all duration-200">
          <div className="flex items-center justify-between">
            <div>
              <p className="text-sm font-medium text-green-700 dark:text-green-300 mb-1">
                Published
              </p>
              <p className="text-3xl font-bold text-green-900 dark:text-green-100">
                0
              </p>
              <p className="text-xs text-green-600/70 dark:text-green-400/70 mt-1">
                Live di website
              </p>
            </div>
            <div className="w-12 h-12 bg-green-200/50 dark:bg-green-800/30 rounded-xl flex items-center justify-center group-hover:scale-110 transition-transform">
              <div className="w-6 h-6 bg-green-500 rounded"></div>
            </div>
          </div>
        </div>
      </div>

      {/* Quick Actions */}
      <div className="bg-background/80 backdrop-blur-sm p-6 rounded-xl border border-border/50 shadow-sm">
        <div className="flex items-center justify-between mb-6">
          <div>
            <h3 className="text-lg font-semibold text-foreground">
              Quick Actions
            </h3>
            <p className="text-sm text-muted-foreground">
              Akses cepat ke fitur yang sering digunakan
            </p>
          </div>
        </div>
        <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
          <Button
            variant="secondary"
            className="h-auto p-4 flex flex-col items-start space-y-2 hover:scale-105 transition-transform"
          >
            <div className="w-8 h-8 bg-primary/20 rounded-lg flex items-center justify-center mb-2">
              <div className="w-4 h-4 bg-primary rounded"></div>
            </div>
            <div className="text-left">
              <p className="font-medium">Buat Post Baru</p>
              <p className="text-xs text-muted-foreground">
                Tulis artikel baru
              </p>
            </div>
          </Button>

          <Button
            variant="outline"
            className="h-auto p-4 flex flex-col items-start space-y-2 hover:scale-105 transition-transform"
          >
            <div className="w-8 h-8 bg-muted rounded-lg flex items-center justify-center mb-2">
              <div className="w-4 h-4 bg-muted-foreground rounded"></div>
            </div>
            <div className="text-left">
              <p className="font-medium">Kelola Categories</p>
              <p className="text-xs text-muted-foreground">Organisasi konten</p>
            </div>
          </Button>

          <Button
            variant="outline"
            className="h-auto p-4 flex flex-col items-start space-y-2 hover:scale-105 transition-transform"
          >
            <div className="w-8 h-8 bg-muted rounded-lg flex items-center justify-center mb-2">
              <div className="w-4 h-4 bg-muted-foreground rounded"></div>
            </div>
            <div className="text-left">
              <p className="font-medium">Kelola Tags</p>
              <p className="text-xs text-muted-foreground">Label untuk post</p>
            </div>
          </Button>
        </div>
      </div>

      {/* Recent Activity */}
      <div className="bg-background/80 backdrop-blur-sm p-6 rounded-xl border border-border/50 shadow-sm">
        <h3 className="text-lg font-semibold text-foreground mb-4">
          Aktivitas Terbaru
        </h3>
        <div className="space-y-4">
          <div className="flex items-center space-x-4 p-3 rounded-lg bg-muted/30">
            <div className="w-2 h-2 bg-blue-500 rounded-full"></div>
            <div className="flex-1">
              <p className="text-sm font-medium text-foreground">
                Selamat datang di BWA Blog CMS
              </p>
              <p className="text-xs text-muted-foreground">
                Mulai dengan membuat post pertama Anda
              </p>
            </div>
            <p className="text-xs text-muted-foreground">Baru saja</p>
          </div>
        </div>
      </div>
    </div>
  );
}

Halaman Dashboard
Halaman Dashboard

Testing Authentication System

Sekarang authentication system kita sudah lengkap! Coba jalankan development server:

npm run dev

Setelah berhasil register dengan role default "user", kamu akan redirect ke homepage. Untuk testing sebagai admin, jalankan script untuk create admin user. Buat file src/scripts/seed-admin.ts:

import { eq } from "drizzle-orm";
import { db } from "@/db";
import { user } from "@/db/auth-schema";
import { auth } from "@/lib/auth";

async function seedAdmin() {
  try {
    // Register admin user dengan Better Auth
    const data = await auth.api.signUpEmail({
      body: {
        name: "Admin BWA",
        username: "admin",
        email: "[email protected]",
        password: "password123",
      },
    });

    if (!data) {
      console.log("Gagal membuat user");
      process.exit();
    } else {
      // Jika berhasil register, update role jadi admin
      await db
        .update(user)
        .set({ role: "admin" })
        .where(eq(user.id, data.user.id));

      console.log(
        `Admin user berhasil dibuat: ${data.user.name} (${data.user.email})`,
      );
    }
  } catch (error) {
    console.error("Error seeding admin:", error);
  } finally {
    process.exit(0);
  }
}

seedAdmin();

Tambahkan script di package.json:

{
  "scripts": {
    "seed:admin": "bun src/scripts/seed-admin.ts"
  }
}

Note: Script ini menggunakan Bun runtime. Pastikan Bun sudah terinstall di sistem kamu.

Jalankan script untuk create admin:

npm run seed:admin

Buat akun admin

Buat akun admin

Authentication system kita sekarang sudah complete dengan multi-user support, role-based access control, username login, protected routes, dan session management yang secure. User dengan role "admin" dan "author" bisa akses dashboard, sedangkan role "user" tetap bisa login tapi hanya akses homepage untuk commenting nanti. Di chapter selanjutnya, kita akan mulai build interface dashboard untuk managing blog posts dan user management!

Rangkuman Perjalanan Membangun CMS Blog

Nah teman-teman, sampai sini kita udah berhasil membangun fondasi CMS blog yang solid banget. Dari awal yang cuma setup project Next.js kosong, sekarang kita udah punya sistem authentication yang kuat dengan Better Auth, database schema yang terstruktur dengan Drizzle ORM, dan koneksi ke Supabase yang stabil.

Perjalanan ini emang ga mudah ya, apalagi buat pemula yang baru kenal dengan teknologi-teknologi modern ini. Tapi dengan mengikuti setiap langkah secara bertahap, kamu udah berhasil memahami konsep dasar bagaimana aplikasi web modern dibangun. Mulai dari setup environment variables, konfigurasi database, sampe implementasi Row Level Security di Supabase.

Yang paling penting adalah kamu udah punya struktur project yang scalable dan maintainable. Struktur folder yang rapi, type definitions yang jelas dengan TypeScript, dan komponen-komponen yang reusable. Ini semua adalah fondasi yang bakal memudahkan kamu dalam mengembangkan fitur-fitur lanjutan nantinya.

Apa yang Sudah Kita Capai

Kalau kita lihat kembali, pencapaian kita cukup mengesankan lho. Kita udah berhasil setup Next.js 15 dengan App Router yang merupakan paradigma terbaru dalam development React. BiomeJS sebagai linter dan formatter yang performanya jauh lebih cepat dari ESLint dan Prettier konvensional juga udah terintegrasi dengan baik.

Database schema yang kita rancang udah mencakup semua entitas yang dibutuhin untuk blog CMS yang lengkap. Tabel posts, categories, tags, comments, sampe media management udah ter-design dengan baik dan mengikuti prinsip normalisasi database. Relationships antar tabel juga udah didefinisikan dengan proper menggunakan foreign keys dan junction tables.

Better Auth integration kita juga udah solid dengan fitur username login, role-based access control, dan session management yang secure. User bisa register sebagai role default, tapi admin bisa promote mereka jadi author atau admin sesuai kebutuhan. Middleware protection juga udah aktif untuk melindungi routes yang sensitive.

Persiapan untuk Chapter Selanjutnya

Chapter berikutnya akan fokus pada Rich Text Editor yang merupakan jantung dari CMS blog kita. Kita bakal mengintegrasikan TipTap editor yang powerful banget dengan berbagai fitur advanced kayak formatting toolbar, markdown shortcuts, image insertion, dan preview mode yang real-time.

TipTap editor ini pilihan yang tepat karena dia modular, customizable, dan punya performa yang excellent. Plus, integrasinya dengan React sangat smooth dan dokumentasinya lengkap. Kita bakal belajar gimana cara setup extensions, bikin custom toolbar, dan mengimplementasikan keyboard shortcuts yang bikin writing experience jadi lebih productive.

Fitur image upload juga bakal kita bahas detail, mulai dari setup storage bucket di Supabase, implementasi drag and drop functionality, sampe optimasi image dengan resize dan compression otomatis. Semua ini bakal diintegrasikan seamlessly dengan editor kita.

Tetap Semangat Belajar

Sebelum lanjut ke chapter berikutnya, pastikan kamu udah comfortable dengan semua yang udah kita pelajari sejauh ini. Coba eksperimen dengan menambah field baru di schema, atau bikin custom middleware untuk logging. Semakin banyak kamu practice, semakin dalam pemahaman kamu tentang teknologi-teknologi ini.

Buat yang mau belajar lebih dalam tentang Next.js, Supabase, dan teknologi web modern lainnya, BuildWithAngga punya kelas-kelas premium yang dirancang khusus untuk developer Indonesia. Materinya selalu update mengikuti trend terbaru dan dijelaskan dalam bahasa Indonesia yang mudah dipahami.