Tujuan
Di proyek ini kamu menggabungkan semua yang sudah dipelajari di Fase 4 menjadi satu aplikasi utuh yang layak dipamerkan di portofolio: blog full-stack dengan halaman publik yang cepat dan ramah SEO, serta dashboard admin untuk menulis, mengedit, menerbitkan, dan menghapus artikel. Pengunjung biasa hanya bisa membaca; admin harus login dulu.
Proyek ini sengaja dirancang agar setiap modul bagian 1 dan bagian 2 punya perannya:
| Bagian aplikasi | Konsep yang dipakai |
|---|---|
| Beranda dan halaman artikel | App Router, Server Component, 'use cache', <Suspense>, generateMetadata |
| Form tulis/edit artikel | Server Actions, useActionState, useFormStatus, Zod |
| Daftar artikel di dashboard | useOptimistic, startTransition |
| Penyimpanan | Drizzle ORM + libSQL (SQLite lokal, Turso di produksi), server-only |
| Login admin | Session JWT dengan jose, cookie httpOnly, Data Access Layer |
Gerbang /admin |
proxy.js dengan matcher |
| API untuk aplikasi lain | Route Handler GET /api/posts dan /api/posts/[slug] |
| SEO | metadata, sitemap.js, robots.js, canonical, Open Graph |
| Kualitas | Vitest + Testing Library, Playwright |
| Online | Deploy ke Vercel + Turso |
Kerangka kode di bawah sudah diuji: npm run build sukses dengan Next.js 16.4.3, 3 unit/component test lolos, dan 3 test E2E Playwright lolos (beranda, proteksi admin, serta login lalu membuat artikel). Kamu boleh mengetik ulang semuanya sebagai latihan, atau menyalin lalu fokus memahami setiap baris dan mengerjakan pengembangan lanjutan.
Fitur wajib
- Beranda (
/) menampilkan daftar artikel yang sudah terbit, terbaru di atas, dengan judul dan ringkasan. - Halaman artikel (
/blog/[slug]) menampilkan judul, tanggal, dan isi; artikel draf atau slug yang tidak ada menghasilkan 404. - Login admin (
/login) dengan satu password dari environment variable; pesan error jelas bila salah; tombol loading saat diproses. - Dashboard (
/admin) berisi daftar semua artikel (terbit dan draf), tombol Edit, Terbitkan/Jadikan draf, dan Hapus yang terasa instan (optimistis). - CRUD lewat Server Actions: buat (
/admin/new), edit (/admin/[id]/edit), hapus, dan ubah status terbit, semuanya dengan validasi Zod di server dan cek admin di setiap action. - Proteksi berlapis: Proxy me-redirect pengunjung tanpa session dari
/adminke/login, dan DAL tetap memverifikasi sebelum membaca data admin. - SEO: judul dan deskripsi per artikel, URL canonical, Open Graph,
sitemap.xmlberisi semua artikel terbit,robots.txtyang menutup/admindan/login. - API publik:
GET /api/posts(daftar) danGET /api/posts/[slug](detail, 404 bila tidak ada). - Test: minimal satu unit test, satu component test, dan satu test E2E alur penting.
- Deploy ke Vercel dengan database Turso dan env produksi yang terpisah dari laptop.
Langkah bertahap
Langkah 1, Siapkan proyek
npm view next version # cek versi terbaru (saat ditulis: 16.4.3)
npx create-next-app@latest blog --js --app --eslint --tailwind --no-src-dir --import-alias "@/*" --use-npm --yes
cd blog
npm install drizzle-orm @libsql/client zod jose server-only
npm install -D drizzle-kit vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom @playwright/test
Buka next.config.mjs dan pastikan ada cacheComponents: true dan partialPrefetching: true (sudah diisi otomatis oleh create-next-app 16.4). Buat .env.local:
printf 'SESSION_SECRET=%s\nADMIN_PASSWORD=rahasia-belajar-123\nREVALIDATE_SECRET=%s\nNEXT_PUBLIC_SITE_URL=http://localhost:3000\n' \
"$(openssl rand -base64 32)" "$(openssl rand -hex 16)" > .env.local
Langkah 2, Database
Tulis db/schema.js, drizzle.config.js, dan lib/db.js (lihat kerangka). Jalankan npx drizzle-kit push, lalu npm run db:seed untuk mengisi satu artikel contoh. Cek dengan membuka local.db memakai ekstensi SQLite di editor, atau jalankan npx drizzle-kit studio.
Langkah 3, Session dan Data Access Layer
Tulis lib/session.js (encrypt, decrypt, createSession, deleteSession) dan lib/dal.js (verifyAdmin + fungsi query). Semua file ini diawali import 'server-only'.
Langkah 4, Halaman publik
Tulis app/layout.js (metadata dasar dengan metadataBase), app/page.js (beranda), dan app/blog/[slug]/page.js (artikel + generateMetadata). Jalankan npm run dev dan pastikan artikel seed tampil.
Langkah 5, Login, Proxy, dan dashboard
Tulis app/actions/auth.js, app/login/page.js + LoginForm.jsx, proxy.js, lalu app/admin/page.js dengan components/PostRows.jsx. Coba buka /admin tanpa login (harus ke /login), lalu login.
Langkah 6, CRUD dengan Server Actions
Tulis app/actions/posts.js, components/SubmitButton.jsx, components/PostForm.jsx, app/admin/new/page.js, dan app/admin/[id]/edit/page.js. Uji validasi (judul "Hi" harus ditolak), buat artikel, edit, ubah status, dan hapus.
Langkah 7, API, SEO, dan test
Tulis app/api/posts/route.js, app/api/posts/[slug]/route.js, app/sitemap.js, app/robots.js. Tambahkan vitest.config.mjs, playwright.config.mjs, dan test di __tests__/ serta e2e/. Jalankan npm test, lalu npm run build && npm run test:e2e.
Langkah 8, Deploy
Push ke GitHub, buat database Turso, drizzle-kit push + seed ke Turso, isi semua env di Vercel (secret baru!), deploy, lalu verifikasi dengan curl seperti di Modul 10.
Kode kerangka
Struktur folder akhir:
blog/
├── app/
│ ├── actions/
│ │ ├── auth.js # login, logout
│ │ └── posts.js # createPost, updatePost, deletePost, togglePublish
│ ├── admin/
│ │ ├── [id]/edit/page.js
│ │ ├── new/page.js
│ │ └── page.js # dashboard
│ ├── api/
│ │ ├── posts/[slug]/route.js
│ │ ├── posts/route.js
│ │ └── revalidate/route.js
│ ├── blog/[slug]/page.js
│ ├── login/
│ │ ├── LoginForm.jsx
│ │ └── page.js
│ ├── globals.css
│ ├── layout.js
│ ├── page.js
│ ├── robots.js
│ └── sitemap.js
├── components/ PostForm.jsx PostRows.jsx SubmitButton.jsx
├── db/schema.js
├── lib/ dal.js db.js session.js slugify.js
├── scripts/seed.mjs
├── __tests__/ slugify.test.js SubmitButton.test.jsx
├── e2e/blog.spec.mjs
├── proxy.js
├── drizzle.config.js vitest.config.mjs playwright.config.mjs next.config.mjs
└── .env.local # TIDAK di-commit
File yang sudah ditampilkan lengkap di modul sebelumnya dan dipakai apa adanya: db/schema.js, drizzle.config.js, lib/db.js (Modul 4); lib/session.js, app/actions/auth.js, app/login/LoginForm.jsx (Modul 6); app/actions/posts.js versi createPost, components/SubmitButton.jsx, components/PostForm.jsx (Modul 2); components/PostRows.jsx (Modul 3); proxy.js (Modul 5); app/api/posts/[slug]/route.js dan app/api/revalidate/route.js (Modul 1); konfigurasi dan file test (Modul 9). Berikut sisanya.
// lib/slugify.js, fungsi murni, mudah dites
export function slugify(text) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[̀-ͯ]/g, '') // buang aksen: é → e
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// lib/dal.js, satu-satunya pintu ke data
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { cacheLife, cacheTag } from 'next/cache'
import { eq, desc, and } from 'drizzle-orm'
import { db } from '@/lib/db'
import { posts } from '@/db/schema'
import { decrypt } from '@/lib/session'
// ---------- Autentikasi ----------
export const verifyAdmin = cache(async () => {
const token = (await cookies()).get('session')?.value
const session = await decrypt(token)
if (session?.role !== 'admin') redirect('/login')
return { role: 'admin' }
})
// ---------- Data publik (di-cache, ikut static shell) ----------
export async function getPublishedPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return db
.select({ slug: posts.slug, title: posts.title, excerpt: posts.excerpt, createdAt: posts.createdAt })
.from(posts)
.where(eq(posts.published, true))
.orderBy(desc(posts.createdAt))
}
export async function getPublishedPost(slug) {
'use cache'
cacheLife('hours')
cacheTag('posts', `post-${slug}`)
const rows = await db
.select()
.from(posts)
.where(and(eq(posts.slug, slug), eq(posts.published, true)))
.limit(1)
return rows[0] ?? null
}
// ---------- Data admin (selalu segar, wajib login) ----------
export async function getAllPostsForAdmin() {
await verifyAdmin()
return db
.select({ id: posts.id, title: posts.title, slug: posts.slug, published: posts.published })
.from(posts)
.orderBy(desc(posts.createdAt))
}
export async function getPostById(id) {
await verifyAdmin()
const rows = await db.select().from(posts).where(eq(posts.id, id)).limit(1)
return rows[0] ?? null
}
// app/actions/posts.js, bagian setelah createPost (lihat Modul 2 untuk awal file)
export async function updatePost(id, prevState, formData) {
await verifyAdmin()
const values = readForm(formData)
const parsed = PostSchema.safeParse(values)
if (!parsed.success) {
return { errors: z.flattenError(parsed.error).fieldErrors, values }
}
const [row] = await db
.update(posts)
.set(parsed.data)
.where(eq(posts.id, id))
.returning({ slug: posts.slug })
updateTag('posts')
if (row) updateTag(`post-${row.slug}`)
redirect('/admin')
}
export async function deletePost(id) {
await verifyAdmin()
await db.delete(posts).where(eq(posts.id, id))
updateTag('posts')
}
export async function togglePublish(id, published) {
await verifyAdmin()
const [row] = await db
.update(posts)
.set({ published })
.where(eq(posts.id, id))
.returning({ slug: posts.slug })
updateTag('posts')
if (row) updateTag(`post-${row.slug}`)
}
// Jangan lupa: import { eq } from 'drizzle-orm' di atas file.
// app/layout.js
import Link from 'next/link'
import './globals.css'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
export const metadata = {
metadataBase: new URL(siteUrl), // URL relatif di metadata jadi absolut
title: { default: 'Blog Kodingup', template: '%s | Blog Kodingup' },
description: 'Catatan belajar coding full-stack dengan Next.js.',
openGraph: { type: 'website', siteName: 'Blog Kodingup' },
}
export default function RootLayout({ children }) {
return (
<html lang="id">
<body className="mx-auto flex min-h-screen max-w-2xl flex-col gap-8 p-6">
<header className="flex justify-between">
<Link href="/" className="font-bold">Blog Kodingup</Link>
<Link href="/admin" className="text-sm underline">Admin</Link>
</header>
<main>{children}</main>
</body>
</html>
)
}
// app/blog/[slug]/page.js
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getPublishedPost } from '@/lib/dal'
export async function generateMetadata({ params }) {
const { slug } = await params
const post = await getPublishedPost(slug)
if (!post) return { title: 'Artikel tidak ditemukan' }
return {
title: post.title, // → "Halo Dunia | Blog Kodingup" lewat template di layout
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
openGraph: { type: 'article', title: post.title, description: post.excerpt },
}
}
async function PostBody({ params }) {
const { slug } = await params
const post = await getPublishedPost(slug)
if (!post) notFound()
return (
<article className="flex flex-col gap-4">
<h1 className="text-3xl font-bold">{post.title}</h1>
<time className="text-sm text-gray-500" dateTime={post.createdAt.toISOString()}>
{post.createdAt.toLocaleDateString('id-ID', { dateStyle: 'long' })}
</time>
{post.content.split(/\n\s*\n/).map((para, i) => (
<p key={i}>{para}</p>
))}
</article>
)
}
// params = data request → dibaca di dalam Suspense, sisa halaman tetap prerender
export default function PostPage({ params }) {
return (
<Suspense fallback={<p>Memuat artikel...</p>}>
<PostBody params={params} />
</Suspense>
)
}
// app/login/page.js
import { LoginForm } from './LoginForm'
export const metadata = { title: 'Login Admin', robots: { index: false } }
export default function LoginPage() {
return (
<section className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Login Admin</h1>
<LoginForm />
</section>
)
}
// app/admin/new/page.js
import { Suspense } from 'react'
import { verifyAdmin } from '@/lib/dal'
import { createPost } from '@/app/actions/posts'
import { PostForm } from '@/components/PostForm'
async function Guarded() {
await verifyAdmin() // halaman juga dicek, bukan hanya action-nya
return <PostForm action={createPost} initial={{ title: '', excerpt: '', content: '', published: false }} />
}
export default function NewPostPage() {
return (
<section className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Artikel Baru</h1>
<Suspense fallback={<p>Memeriksa sesi...</p>}>
<Guarded />
</Suspense>
</section>
)
}
// app/admin/[id]/edit/page.js
import { Suspense } from 'react'
import { notFound } from 'next/navigation'
import { getPostById } from '@/lib/dal'
import { updatePost } from '@/app/actions/posts'
import { PostForm } from '@/components/PostForm'
async function EditForm({ params }) {
const { id } = await params
const post = await getPostById(Number(id)) // sudah memanggil verifyAdmin()
if (!post) notFound()
const action = updatePost.bind(null, post.id) // → updatePost(id, prevState, formData)
return <PostForm action={action} initial={post} />
}
export default function EditPostPage({ params }) {
return (
<section className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Edit Artikel</h1>
<Suspense fallback={<p>Memuat...</p>}>
<EditForm params={params} />
</Suspense>
</section>
)
}
Halaman app/admin/page.js dan app/page.js sama dengan yang ditampilkan di Modul 6 dan Modul 4 (tambahkan class Tailwind sesuai selera). Berikut file API, SEO, dan seed:
// app/api/posts/route.js
import { getPublishedPosts } from '@/lib/dal'
export async function GET() {
const posts = await getPublishedPosts()
return Response.json({ count: posts.length, posts })
}
// → {"count":1,"posts":[{"slug":"halo-dunia","title":"Halo Dunia",...}]}
// app/sitemap.js
import { getPublishedPosts } from '@/lib/dal'
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
export default async function sitemap() {
const posts = await getPublishedPosts()
return [
{ url: siteUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
...posts.map((post) => ({
url: `${siteUrl}/blog/${post.slug}`,
lastModified: post.createdAt,
changeFrequency: 'monthly',
priority: 0.7,
})),
]
}
// app/robots.js
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? 'http://localhost:3000'
export default function robots() {
return {
rules: { userAgent: '*', allow: '/', disallow: ['/admin', '/login'] },
sitemap: `${siteUrl}/sitemap.xml`,
}
}
// /robots.txt → User-Agent: * Allow: / Disallow: /admin Disallow: /login Sitemap: .../sitemap.xml
// scripts/seed.mjs, dijalankan dengan: npm run db:seed
import { createClient } from '@libsql/client'
const client = createClient({
url: process.env.DATABASE_URL ?? 'file:local.db',
authToken: process.env.DATABASE_AUTH_TOKEN,
})
await client.execute({
sql: `INSERT OR IGNORE INTO posts (slug, title, excerpt, content, published, created_at)
VALUES (?, ?, ?, ?, 1, ?)`,
args: [
'halo-dunia',
'Halo Dunia',
'Artikel pertama di blog full-stack Next.js.',
'Ini paragraf pertama.\n\nIni paragraf kedua, ditulis dari seed script.',
Math.floor(Date.now() / 1000), // kolom timestamp Drizzle disimpan dalam detik
],
})
console.log('Seed selesai')
Tambahkan script berikut ke package.json:
{
"scripts": {
"db:push": "drizzle-kit push",
"db:seed": "node --env-file-if-exists=.env.local scripts/seed.mjs",
"test": "vitest run",
"test:e2e": "playwright test"
}
}
Hasil npm run build yang diharapkan (ringkasan route):
Route (app) Revalidate Expire
┌ ○ / 1h 1d
├ ◐ /admin
├ /admin/[id]/edit
│ └ ◐ /admin/[id]/edit
├ ◐ /admin/new
├ ○ /api/posts 1h 1d
├ ƒ /api/posts/[slug]
├ ƒ /api/revalidate
├ /blog/[slug]
│ └ ◐ /blog/[slug]
├ ○ /login
├ ○ /robots.txt
└ ƒ /sitemap.xml
ƒ Proxy (Middleware)
Perhatikan bagaimana setiap simbol mencerminkan keputusanmu: beranda dan /api/posts statis karena datanya 'use cache'; halaman admin dan artikel ◐ karena punya bagian di dalam <Suspense>; sitemap ƒ karena memakai new Date().
Kriteria lulus
- [ ]
npm run buildsukses tanpa error dan tanpa peringatan "blocking route" dinpm run dev. - [ ] Beranda hanya menampilkan artikel terbit, urut dari terbaru.
- [ ]
/blog/<slug-draf>dan/blog/tidak-adamenghasilkan 404. - [ ]
/admintanpa login me-redirect ke/login(cek dengancurl -I, status 307). - [ ] Password salah menampilkan pesan error; password benar masuk ke dashboard.
- [ ] Judul kurang dari 3 karakter ditolak dengan pesan di bawah field, dan isian lain tidak hilang.
- [ ] Artikel baru yang dicentang "Terbitkan" langsung muncul di beranda setelah disimpan.
- [ ] Tombol Hapus dan Terbitkan/Jadikan draf mengubah daftar seketika (optimistis) dan bertahan setelah refresh.
- [ ] Memanggil Server Action tanpa cookie session tidak bisa mengubah data (action memanggil
verifyAdmin()). - [ ]
/sitemap.xmlmemuat semua artikel terbit;/robots.txtmenutup/admindan/login. - [ ] Halaman artikel punya
<title>, meta description, canonical, dan tag Open Graph (cek dengan View Source). - [ ]
GET /api/postsmengembalikan JSON;GET /api/posts/tidak-adamengembalikan 404. - [ ]
npm testdannpm run test:e2ehijau. - [ ]
.env.localtidak ada di repositori Git. - [ ] Aplikasi live di Vercel dengan database Turso, dan
NEXT_PUBLIC_SITE_URLmenunjuk ke domain produksi.
Pengembangan lanjutan
- Konten MDX: izinkan admin menulis isi artikel dengan Markdown dan render dengan pustaka Markdown/MDX (misalnya
@next/mdx16.4.3 untuk file.mdxstatis, atau library pengubah Markdown untuk konten dari database). Ingat sanitasi HTML bila merender teks dari pengguna. - Gambar sampul: tambahkan kolom
coverUrl, tampilkan dengannext/image, isiopenGraph.images, dan daftarkan domain gambar diimages.remotePatterns. - Multi-penulis: pindah ke Better Auth dengan adapter Drizzle, tambahkan peran
authordanadmin, lalu batasi agar penulis hanya bisa mengedit artikelnya sendiri (cek di DAL!). - Komentar pembaca dengan tabel
comments, Server Action, validasi Zod,useOptimistic, dan rate limiting sederhana per IP. - Generate static params: tambahkan
generateStaticParamsdi/blog/[slug]yang mengembalikan beberapa slug terpopuler (minimal satu, aturan Cache Components) agar halaman tersebut prerender penuh. - Feed RSS lewat Route Handler
app/rss.xml/route.jsyang mengembalikan XML dengan headerContent-Type: application/rss+xml. - CI: workflow GitHub Actions yang menjalankan
npm ci,npm test,npm run build, dannpm run test:e2edi setiap pull request sebelum Vercel mendeploy.