kodingup.camp

Beranda/Next.js Mastery: Full-Stack, Auth & Deploy

Proyek Akhir Fase 4

Modul 11 dari 116 menit bacaBlog Full-Stack dengan Next.js

Isi modul

    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

    1. Beranda (/) menampilkan daftar artikel yang sudah terbit, terbaru di atas, dengan judul dan ringkasan.
    2. Halaman artikel (/blog/[slug]) menampilkan judul, tanggal, dan isi; artikel draf atau slug yang tidak ada menghasilkan 404.
    3. Login admin (/login) dengan satu password dari environment variable; pesan error jelas bila salah; tombol loading saat diproses.
    4. Dashboard (/admin) berisi daftar semua artikel (terbit dan draf), tombol Edit, Terbitkan/Jadikan draf, dan Hapus yang terasa instan (optimistis).
    5. 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.
    6. Proteksi berlapis: Proxy me-redirect pengunjung tanpa session dari /admin ke /login, dan DAL tetap memverifikasi sebelum membaca data admin.
    7. SEO: judul dan deskripsi per artikel, URL canonical, Open Graph, sitemap.xml berisi semua artikel terbit, robots.txt yang menutup /admin dan /login.
    8. API publik: GET /api/posts (daftar) dan GET /api/posts/[slug] (detail, 404 bila tidak ada).
    9. Test: minimal satu unit test, satu component test, dan satu test E2E alur penting.
    10. 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

    Pengembangan lanjutan

    1. Konten MDX: izinkan admin menulis isi artikel dengan Markdown dan render dengan pustaka Markdown/MDX (misalnya @next/mdx 16.4.3 untuk file .mdx statis, atau library pengubah Markdown untuk konten dari database). Ingat sanitasi HTML bila merender teks dari pengguna.
    2. Gambar sampul: tambahkan kolom coverUrl, tampilkan dengan next/image, isi openGraph.images, dan daftarkan domain gambar di images.remotePatterns.
    3. Multi-penulis: pindah ke Better Auth dengan adapter Drizzle, tambahkan peran author dan admin, lalu batasi agar penulis hanya bisa mengedit artikelnya sendiri (cek di DAL!).
    4. Komentar pembaca dengan tabel comments, Server Action, validasi Zod, useOptimistic, dan rate limiting sederhana per IP.
    5. Generate static params: tambahkan generateStaticParams di /blog/[slug] yang mengembalikan beberapa slug terpopuler (minimal satu, aturan Cache Components) agar halaman tersebut prerender penuh.
    6. Feed RSS lewat Route Handler app/rss.xml/route.js yang mengembalikan XML dengan header Content-Type: application/rss+xml.
    7. CI: workflow GitHub Actions yang menjalankan npm ci, npm test, npm run build, dan npm run test:e2e di setiap pull request sebelum Vercel mendeploy.

    Video pendamping

    Kalau penjelasan di atas belum masuk, tonton ini. Video dari kanal YouTube pihak lain.

    Next.js Full Stack w/ Server Actions - Full WalkthroughKevin Wade · Bahasa InggrisBuka di YouTube

    Kuis pemahaman

    Pilih satu jawaban per soal. Penjelasan muncul setelah kamu menjawab.