Panduan Lengkap Membangun Sistem Glosarium Dinamis dan "Smart Link" Menggunakan Next.js, Prisma, dan Cloudflare D1

PE
Pena Edukasi
Ilustrasi realistis tutorial membuat Smart Link Tooltip interaktif pada website menggunakan Next.js, Prisma ORM, dan Cloudflare D1 untuk menampilkan definisi istilah teknis secara otomatis saat pengguna mengarahkan kursor.

Gambar: Tutorial membuat Smart Link Tooltip interaktif menggunakan Next.js, Prisma ORM, dan Cloudflare D1 untuk menampilkan definisi istilah teknis pada website Pena Edukasi dummy

Dalam era digital modern, sebuah platform edukasi, portal berita, atau sistem manajemen pengetahuan (Knowledge Management System) yang kaya akan informasi menuntut lebih dari sekadar teks statis. Pengunjung modern membutuhkan kemudahan dalam memahami istilah-istilah teknis tanpa harus meninggalkan halaman yang sedang mereka baca.

Bayangkan ketika seorang pembaca menemukan istilah kompleks seperti "Verifikasi Biometrik" atau "Deep Learning". Alih-alih membuka tab baru di peramban untuk mencari artinya, situs web Anda sudah menyediakan kotak informasi instan (Tooltip) yang muncul secara otomatis saat kata tersebut disorot kursor. Inilah yang kita sebut sebagai fitur Smart Link.

Artikel ini akan mendokumentasikan panduan langkah demi langkah (step-by-step) yang komprehensif tentang cara membangun arsitektur web modern ini menggunakan Next.js (App Router), Prisma ORM, dan database tanpa peladen (serverless) Cloudflare D1.

Persiapan Lingkungan dan Konfigurasi Awal

Langkah pertama adalah mengatur kerangka kerja proyek, memasang pustaka (libraries) yang dibutuhkan melalui terminal, serta menghubungkan proyek lokal kita ke ekosistem Cloudflare.

Langkah 1: Instalasi Prisma dan Pembuatan Database D1

Buka terminal di dalam Visual Studio Code (VS Code) proyek Next.js Anda, lalu jalankan perintah berikut secara berurutan:

  1. Install Prisma:
    npm install prisma --save-dev
  2. Inisialisasi Prisma: Perintah ini akan membuat folder prisma dan file schema.prisma.
    npx prisma init
  3. Buat Database di Cloudflare D1: Perintah ini akan membuat ruang database baru di akun Cloudflare Anda dan menghasilkan database_id.
    npx wrangler d1 create kamus-ensiklopedia
  4. Generate Klien Prisma: Perintah ini berfungsi menjembatani kode Next.js Anda agar bisa membaca skema database yang baru saja dibuat.
    npx prisma generate
  5. Membuat File Migrasi SQL: Perintah ini akan menyuruh Cloudflare D1 menyiapkan instruksi pembuatan tabel baru (ganti db-pena jika nama database Anda berbeda).
    npx wrangler d1 migrations create db-pena buat_tabel_glosarium
  6. Eksekusi Tabel di Localhost: Perintah ini secara nyata akan menciptakan tabel di dalam komputer Anda untuk mode pengembangan lokal.
    npx wrangler d1 migrations apply db-pena --local
  7. Meng-Online-kan Database ke Server Cloudflare: Anda wajib mengirimkan kerangka tabel ke server Cloudflare (Production) agar database tersedia di internet. Tekan "Y" jika terminal meminta konfirmasi.
    npx wrangler d1 migrations apply db-pena --remote

Langkah 2: Konfigurasi Dependensi (package.json)

Pastikan file konfigurasi utama Anda menyertakan pustaka untuk Cloudflare dan komponen UI. Berikut adalah struktur package.json yang menjamin semua alat bekerja harmonis.

1. File: package.json

{
  "name": "web-ensiklopedia",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint .",
    "db:generate": "prisma generate",
    "pages:build": "npx @cloudflare/next-on-pages",
    "postinstall": "prisma generate"
  },
  "dependencies": {
    "@cloudflare/next-on-pages": "^1.13.16",
    "@prisma/adapter-d1": "^7.10.0",
    "@radix-ui/react-tooltip": "1.1.6",
    "lucide-react": "^0.544.0",
    "next": "16.1.6",
    "react": "^19",
    "react-dom": "^19",
    "tailwindcss": "^3.4.17",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@prisma/client": "^7.10.0",
    "prisma": "^7.10.0",
    "typescript": "5.7.3",
    "wrangler": "^4.148.0"
  }
}

Langkah 3: Menghubungkan Proyek ke Cloudflare D1

Gunakan database_id yang Anda dapatkan pada Langkah 1 untuk menyambungkan aplikasi dengan database.

2. File: wrangler.toml

name = "web-ensiklopedia"
compatibility_date = "2024-03-20"
compatibility_flags = ["nodejs_compat"]

[[d1_databases]]
binding = "KAMUS_DB"
database_name = "kamus-ensiklopedia"
# Ganti dengan ID Database D1 asli Anda dari output terminal sebelumnya
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"

Baca Juga: Adab kepada Orang Tua dan Guru: Dua Pintu Keberkahan dalam Hidup

Merancang Skema Database (SQL dan Prisma)

Karena kita menggunakan SQLite di jaringan edge, perancangan skema database harus dilakukan secara presisi. Kita akan membuat tabel untuk Entri Glosarium, Penulis (User), dan Kategori.

Langkah 4: Membuat Skema SQL Awal

Kita perlu mendefinisikan tabel-tabel utama. Buat file SQL baru di root folder Anda.

3. File: skema-awal.sql

CREATE TABLE IF NOT EXISTS "User" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "name" TEXT NOT NULL,
    "title" TEXT,
    "updatedAt" DATETIME NOT NULL
);

CREATE TABLE IF NOT EXISTS "Category" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "name" TEXT NOT NULL
);

CREATE UNIQUE INDEX IF NOT EXISTS "Category_name_key" ON "Category"("name");

CREATE TABLE IF NOT EXISTS "Glossary" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "slug" TEXT NOT NULL,
    "title" TEXT NOT NULL,
    "summary" TEXT NOT NULL,
    "content" TEXT,
    "imageUrl" TEXT,
    "sourceUrl" TEXT,
    "status" TEXT NOT NULL DEFAULT 'DRAFT',
    "hoverCount" INTEGER NOT NULL DEFAULT 0,
    "authorId" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL,
    CONSTRAINT "Glossary_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

CREATE UNIQUE INDEX IF NOT EXISTS "Glossary_slug_key" ON "Glossary"("slug");
CREATE INDEX IF NOT EXISTS "Glossary_slug_status_idx" ON "Glossary"("slug", "status");

CREATE TABLE IF NOT EXISTS "_CategoryToGlossary" (
    "A" TEXT NOT NULL,
    "B" TEXT NOT NULL,
    CONSTRAINT "_CategoryToGlossary_A_fkey" FOREIGN KEY ("A") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT "_CategoryToGlossary_B_fkey" FOREIGN KEY ("B") REFERENCES "Glossary" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

CREATE UNIQUE INDEX IF NOT EXISTS "_CategoryToGlossary_AB_unique" ON "_CategoryToGlossary"("A", "B");
CREATE INDEX IF NOT EXISTS "_CategoryToGlossary_B_index" ON "_CategoryToGlossary"("B");

Langkah 5: Skema SQL Penyempurnaan (Fix) & Eksekusi Tabel

Dalam proses pengembangan, kita sering kali perlu melakukan perbaikan (seperti penambahan kolom createdAt pada tabel User). Buat file skema-fix.sql untuk memastikan database dalam kondisi sempurna.

4. File: skema-fix.sql

-- 1. Hapus tabel lama (reset database)
DROP TABLE IF EXISTS "_CategoryToGlossary";
DROP TABLE IF EXISTS "Glossary";
DROP TABLE IF EXISTS "Category";
DROP TABLE IF EXISTS "User";

-- 2. Buat tabel Glossary
CREATE TABLE "Glossary" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "slug" TEXT NOT NULL,
    "title" TEXT NOT NULL,
    "summary" TEXT NOT NULL,
    "content" TEXT,
    "imageUrl" TEXT,
    "sourceUrl" TEXT,
    "status" TEXT NOT NULL DEFAULT 'DRAFT',
    "hoverCount" INTEGER NOT NULL DEFAULT 0,
    "authorId" TEXT NOT NULL,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL,
    CONSTRAINT "Glossary_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);

-- 3. Buat tabel Category
CREATE TABLE "Category" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "name" TEXT NOT NULL
);

-- 4. Buat tabel User (Sekarang sudah ada createdAt dan updatedAt!)
CREATE TABLE "User" (
    "id" TEXT NOT NULL PRIMARY KEY,
    "name" TEXT NOT NULL,
    "title" TEXT,
    "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    "updatedAt" DATETIME NOT NULL
);

-- 5. Buat tabel Relasi (Kategori ke Istilah)
CREATE TABLE "_CategoryToGlossary" (
    "A" TEXT NOT NULL,
    "B" TEXT NOT NULL,
    CONSTRAINT "_CategoryToGlossary_A_fkey" FOREIGN KEY ("A") REFERENCES "Category" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
    CONSTRAINT "_CategoryToGlossary_B_fkey" FOREIGN KEY ("B") REFERENCES "Glossary" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);

-- 6. Buat Indeks agar pencarian super cepat
CREATE UNIQUE INDEX "Glossary_slug_key" ON "Glossary"("slug");
CREATE INDEX "Glossary_slug_status_idx" ON "Glossary"("slug", "status");
CREATE UNIQUE INDEX "Category_name_key" ON "Category"("name");
CREATE UNIQUE INDEX "_CategoryToGlossary_AB_unique" ON "_CategoryToGlossary"("A", "B");
CREATE INDEX "_CategoryToGlossary_B_index" ON "_CategoryToGlossary"("B");

PENTING: Eksekusi pembuatan tabel di database D1 lokal Anda dengan menjalankan perintah ini di terminal:

npx wrangler d1 execute kamus-ensiklopedia --local --file=skema-fix.sql

Langkah 6: Pemetaan ORM dengan Prisma Schema

Agar aplikasi Next.js dapat membaca SQL di atas, terjemahkan ke Prisma.

5. File: prisma/schema.prisma

generator client {
  provider        = "prisma-client-js"
  previewFeatures = ["driverAdapters"]
}

datasource db {
  provider = "sqlite"
}

model Glossary {
  id          String     @id @default(cuid())
  slug        String     @unique
  title       String
  summary     String
  content     String?
  imageUrl    String?
  sourceUrl   String?
  status      String     @default("DRAFT")
  hoverCount  Int        @default(0) 

  authorId    String
  author      User       @relation(fields: [authorId], references: [id])
  categories  Category[]
  
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt

  @@index([slug, status]) 
}

model Category {
  id          String     @id @default(cuid())
  name        String     @unique
  glossaries  Glossary[]
}

model User {
  id          String     @id @default(cuid())
  name        String
  title       String?    
  glossaries  Glossary[]

  // Dua baris ini yang sebelumnya hilang dan menyebabkan error, kini telah ditambahkan
  createdAt   DateTime   @default(now())
  updatedAt   DateTime   @updatedAt
}

Jangan lupa jalankan: npx prisma generate setelah menyunting file di atas.

Langkah 7: Inisialisasi Prisma Client untuk Edge

6. File: lib/prisma.ts

import { PrismaClient } from "@prisma/client";
import { PrismaD1 } from "@prisma/adapter-d1";
import { getRequestContext } from "@cloudflare/next-on-pages";

export function getPrisma() {
  // Menambahkan "as any" memaksa TypeScript mengenali properti DB Cloudflare
  const env = getRequestContext().env as any;
  const DB = env.DB;
  
  // Menggunakan adapter khusus D1
  const adapter = new PrismaD1(process.env.KAMUS_DB as any)
  return new PrismaClient({ adapter });
}

Baca Juga: Cara Generate dan Verifikasi API Key IndexNow di Bing Webmaster Tools

Membangun Antarmuka Admin dan Sistem Input Data

Langkah 8: API Route untuk Menyimpan Istilah Baru

7. File: app/api/admin/glossary/route.ts

import { NextResponse } from "next/server";
import { getPrisma } from "@/lib/prisma";

// WAJIB ADA: Memberi tahu Next.js bahwa ini berjalan di jaringan Edge Cloudflare
export const runtime = "edge"; 

export async function POST(request: Request) {
  try {
    const body = await request.json() as any;
    const { title, slug, summary, content, imageUrl, sourceUrl } = body;

    const prisma = getPrisma();

    // 1. Pastikan ada 1 user default (Karena relasi Glossary butuh Penulis/Author)
    const defaultUser = await prisma.user.upsert({
      where: { id: "user-admin-1" },
      update: {},
      create: {
        id: "user-admin-1",
        name: "Admin Ensiklopedia",
        title: "Pakar Utama"
      }
    });

    // 2. Simpan data istilah baru ke tabel Glossary
    const newTerm = await prisma.glossary.create({
      data: {
        title,
        slug,
        summary,
        content: content || null,
        imageUrl: imageUrl || null,
        sourceUrl: sourceUrl || null,
        status: "PUBLISHED", // Langsung dipublikasikan agar bisa dibaca
        authorId: defaultUser.id,
      }
    });

    return NextResponse.json({ success: true, data: newTerm }, { status: 201 });
    
  } catch (error: any) {
    console.error("Gagal menyimpan data:", error);
    if (error.code === 'P2002') {
      return NextResponse.json({ success: false, error: "Slug (URL) ini sudah terpakai!" }, { status: 400 });
    }
    return NextResponse.json({ success: false, error: "Terjadi kesalahan server" }, { status: 500 });
  }
}

Langkah 9: Halaman Formulir Tambah Istilah

8. File: app/admin/tambah/page.tsx

"use client";
import { useState } from "react";

export default function TambahIstilahPage() {
  const [isLoading, setIsLoading] = useState(false);
  const [message, setMessage] = useState({ type: "", text: "" });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsLoading(true);
    setMessage({ type: "", text: "" });

    const formData = new FormData(e.currentTarget);
    const data = {
      title: formData.get("title"),
      slug: formData.get("slug"),
      summary: formData.get("summary"),
      content: formData.get("content"),
      imageUrl: formData.get("imageUrl"),
      sourceUrl: formData.get("sourceUrl"),
    };

    try {
      const res = await fetch("/api/admin/glossary", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
      });

      const result = await res.json() as any;

      if (res.ok) {
        setMessage({ type: "success", text: "Berhasil! Istilah baru telah ditambahkan." });
        (e.target as HTMLFormElement).reset(); 
      } else {
        setMessage({ type: "error", text: result.error || "Gagal menambahkan data." });
      }
    } catch (error) {
      setMessage({ type: "error", text: "Terjadi kesalahan pada jaringan." });
    } finally {
      setIsLoading(false);
    }
  };

  return (
    

Tambah Istilah Ensiklopedia

{message.text && (
{message.text}
)}

Huruf kecil semua, tanpa spasi (gunakan tanda strip)

Maksimal disarankan 200 karakter agar tooltip tidak kepanjangan.

); }

Meracik Fitur Inti - "Smart Tooltip"

Langkah 10: API Pengambilan Data Publik

9. File: app/api/glossary/route.ts

import { NextResponse } from "next/server";
import { getPrisma } from "@/lib/prisma";

export const runtime = "edge";

export async function GET() {
  try {
    const prisma = getPrisma();
    
    const terms = await prisma.glossary.findMany({
      where: { status: "PUBLISHED" },
      select: {
        title: true,
        slug: true,
        summary: true,
      },
      orderBy: { title: 'asc' }
    });

    return NextResponse.json({ success: true, data: terms }, { status: 200 });
  } catch (error) {
    console.error("Gagal mengambil data:", error);
    return NextResponse.json({ success: false, error: "Terjadi kesalahan server" }, { status: 500 });
  }
}

Baca Juga: Cara Otomatisasi IndexNow Bing di Next.js dan Cloudflare Pages CMS

Langkah 11: Komponen Logika Smart Tooltip

10. File: components/SmartTooltip.tsx

"use client";

import React, { useEffect, useState, useRef } from "react";

interface GlossaryTerm {
  title: string;
  slug: string;
  summary: string;
}

export default function SmartTooltip({ children }: { children: React.ReactNode }) {
  const [terms, setTerms] = useState<GlossaryTerm[]>([]);
  const containerRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    fetch("/api/glossary")
      .then((res) => res.json())
      .then((data: any) => {
        if (data.success) {
          setTerms(data.data);
        }
      })
      .catch((err) => console.error("Gagal memuat istilah:", err));
  }, []);

  useEffect(() => {
    if (terms.length === 0 || !containerRef.current) return;

    // Urutkan dari yang terpanjang agar frasa majemuk terpindai duluan
    const sortedTerms = [...terms].sort((a, b) => b.title.length - a.title.length);
    const escapeRegExp = (str: string) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const pattern = new RegExp(`\\b(${sortedTerms.map(t => escapeRegExp(t.title)).join('|')})\\b`, 'gi');

    // Objek untuk melacak jumlah kemunculan per istilah
    const termCounts: { [key: string]: number } = {};
    const MAX_PER_TERM = 2;

    const walkTextNodes = (node: Node) => {
      if (node.nodeType === Node.TEXT_NODE) {
        const parent = node.parentNode;
        if (!parent) return;
        if (
          parent.nodeName === 'A' || 
          parent.nodeName === 'SCRIPT' || 
          parent.nodeName === 'STYLE' || 
          (parent as HTMLElement).closest('.group')
        ) {
          return;
        }

        const text = node.nodeValue || "";
        if (!pattern.test(text)) return;
        pattern.lastIndex = 0;

        const frag = document.createDocumentFragment();
        let lastIdx = 0;
        let match;

        while ((match = pattern.exec(text)) !== null) {
          const matchedText = match[0];
          const matchedTerm = sortedTerms.find(
            t => t.title.toLowerCase() === matchedText.toLowerCase()
          );

          if (matchedTerm) {
            const termKey = matchedTerm.title.toLowerCase();
            termCounts[termKey] = (termCounts[termKey] || 0) + 1;

            if (match.index > lastIdx) {
              frag.appendChild(document.createTextNode(text.substring(lastIdx, match.index)));
            }

            // Batasi maksimal 2 kali kemunculan
            if (termCounts[termKey] <= MAX_PER_TERM) {
              const wrapper = document.createElement('span');
              wrapper.className = "relative inline-block group cursor-pointer mx-0.5";
              
              const innerSpan = document.createElement('span');
              innerSpan.className = "border-b-2 border-dotted border-blue-600 text-blue-600 font-medium hover:bg-blue-50 transition-colors px-0.5 rounded";
              innerSpan.textContent = matchedText;
              wrapper.appendChild(innerSpan);

              const tooltip = document.createElement('span');
              tooltip.className = "absolute bottom-full left-1/2 -translate-x-1/2 mb-2 hidden group-hover:block w-72 p-3 bg-gray-900 text-white text-xs rounded-lg shadow-2xl z-50 pointer-events-none";
              
              const strong = document.createElement('strong');
              strong.className = "block font-bold text-blue-300 text-sm mb-1";
              strong.textContent = matchedTerm.title;
              tooltip.appendChild(strong);

              const summarySpan = document.createElement('span');
              summarySpan.className = "text-gray-200 leading-normal";
              summarySpan.textContent = matchedTerm.summary;
              tooltip.appendChild(summarySpan);

              const arrow = document.createElement('span');
              arrow.className = "absolute top-full left-1/2 -translate-x-1/2 -mt-1 border-4 border-transparent border-t-gray-900";
              tooltip.appendChild(arrow);

              wrapper.appendChild(tooltip);
              frag.appendChild(wrapper);
            } else {
              frag.appendChild(document.createTextNode(matchedText));
            }

            lastIdx = pattern.lastIndex;
          }
        }

        if (lastIdx < text.length) {
          frag.appendChild(document.createTextNode(text.substring(lastIdx)));
        }

        parent.replaceChild(frag, node);
      } else if (
        node.nodeType === Node.ELEMENT_NODE && 
        node.nodeName !== 'SCRIPT' && 
        node.nodeName !== 'STYLE' && 
        !(node as HTMLElement).closest('.group')
      ) {
        Array.from(node.childNodes).forEach(walkTextNodes);
      }
    };

    walkTextNodes(containerRef.current);
  }, [terms]);

  return <div ref={containerRef}>{children}</div>;
}

Membangun Direktori dan Routing Dinamis

Langkah 12: Halaman Indeks Utama Glosarium (A-Z)

11. File: app/glosarium/page.tsx

export const runtime = "edge"

import type { Metadata } from "next"
import Link from "next/link"
import { SidebarLeft } from "@/components/sidebar-left"
import { TopBar } from "@/components/top-bar"
import { SidebarRight } from "@/components/sidebar-right"
import { Footer } from "@/components/footer"
import { ScrollToTop } from "@/components/scroll-to-top"
import { getPrisma } from "@/lib/prisma"

async function getAllGlossaryTerms() {
  try {
    const prisma = getPrisma()
    const terms = await prisma.glossary.findMany({
      orderBy: { title: 'asc' },
    })
    return terms
  } catch (error) {
    console.error("Error fetching glossary terms:", error)
    return []
  }
}

export const metadata: Metadata = {
  title: "Glosarium A-Z | Kamus Istilah",
  description: "Jelajahi kumpulan istilah, definisi, dan pengertian terlengkap di Glosarium kami.",
}

export default async function GlosariumIndexPage() {
  const terms = await getAllGlossaryTerms()

  const groupedTerms = terms.reduce((acc: any, term: any) => {
    const firstLetter = term.title.charAt(0).toUpperCase()
    const groupKey = /[A-Z]/.test(firstLetter) ? firstLetter : '#'
    
    if (!acc[groupKey]) acc[groupKey] = []
    acc[groupKey].push(term)
    return acc
  }, {})

  const availableLetters = Object.keys(groupedTerms).sort()

  return (
    <div className="flex min-h-screen w-full overflow-x-clip bg-background">
      <SidebarLeft />
      <div className="flex min-h-screen min-w-0 flex-1 flex-col w-full lg:ml-[270px]">
        <TopBar />
        <div className="mx-auto flex w-full max-w-[1400px] flex-1 items-stretch gap-5 px-0 py-0 md:px-4 md:py-5">
          <main className="min-w-0 w-full flex-1">
            <article className="rounded-none bg-card p-6 md:rounded-2xl md:border md:border-border md:p-8 lg:p-12 shadow-sm min-h-full">
              
              <header className="mb-10 border-b border-border pb-8">
                <h1 className="font-serif text-3xl font-bold text-foreground md:text-4xl lg:text-5xl leading-tight mb-4">
                  Glosarium A-Z
                </h1>
                <p className="text-lg text-muted-foreground max-w-2xl">
                  Kumpulan definisi dan pengertian istilah-istilah penting.
                </p>
              </header>

              {availableLetters.length > 0 ? (
                <div className="space-y-12">
                  {availableLetters.map((letter) => (
                    <section key={letter} id={`huruf-${letter}`} className="scroll-mt-24">
                      <h2 className="text-2xl font-serif font-bold text-blue-600 mb-4 border-b pb-2">
                        {letter}
                      </h2>
                      <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
                        {groupedTerms[letter].map((term: any) => (
                          <Link 
                            key={term.id} 
                            href={`/glosarium/${term.slug}`}
                            prefetch={false}
                            className="block rounded-xl border p-4 hover:border-blue-500 shadow-sm transition-colors"
                          >
                            <h3 className="font-semibold text-foreground mb-1">
                              {term.title}
                            </h3>
                            <p className="text-sm text-muted-foreground line-clamp-2">
                              {term.summary}
                            </p>
                          </Link>
                        ))}
                      </div>
                    </section>
                  ))}
                </div>
              ) : (
                <p className="text-center py-20 text-muted-foreground">Belum ada istilah terdaftar.</p>
              )}
            </article>
          </main>
          <SidebarRight />
        </div>
        <Footer />
      </div>
      <ScrollToTop />
    </div>
  )
}

Langkah 13: Halaman Detail Dinamis Berbasis Slug

12. File: app/glosarium/[slug]/page.tsx

export const runtime = "edge"

import type { Metadata } from "next"
import Link from "next/link"
import { notFound } from "next/navigation"
import { SidebarLeft } from "@/components/sidebar-left"
import { TopBar } from "@/components/top-bar"
import { SidebarRight } from "@/components/sidebar-right"
import { Footer } from "@/components/footer"
import { ScrollToTop } from "@/components/scroll-to-top"
import { getPrisma } from "@/lib/prisma" 

async function getGlossaryTerm(slug: string) {
  try {
    const prisma = getPrisma()
    const term = await prisma.glossary.findUnique({
      where: { slug: slug },
    })
    return term
  } catch (error) {
    console.error("Error fetching glossary term:", error)
    return null
  }
}

export async function generateMetadata({
  params,
}: {
  params: Promise<{ slug: string }>
}): Promise<Metadata> {
  const { slug } = await params
  const term = await getGlossaryTerm(slug)

  if (!term) {
    return {
      title: "Istilah Tidak Ditemukan",
      robots: { index: false, follow: false },
    }
  }

  // Domain contoh dummy
  const siteUrl = "https://www.contoh-domain.com"
  const pageUrl = `${siteUrl}/glosarium/${term.slug}`
  
  const description = term.summary.length > 160 
    ? term.summary.substring(0, 160).trim() + "..." 
    : term.summary

  return {
    title: `Arti Kata ${term.title} | Glosarium`,
    description: `Pengertian dan definisi lengkap dari istilah ${term.title}. ${description}`,
    alternates: {
      canonical: pageUrl,
    },
    openGraph: {
      type: "website",
      locale: "id_ID",
      url: pageUrl,
      siteName: "Glosarium Edukasi",
      title: `Apa itu ${term.title}?`,
      description: description,
    },
    twitter: {
      card: "summary",
      title: `Arti Kata ${term.title}`,
      description: description,
    },
  }
}

export default async function GlosariumPage({
  params,
}: {
  params: Promise<{ slug: string }>
}) {
  const { slug } = await params
  const term = await getGlossaryTerm(slug)

  if (!term) {
    notFound() 
  }

  const siteUrl = "https://www.contoh-domain.com"
  const pageUrl = `${siteUrl}/glosarium/${term.slug}`

  const jsonLd = {
    "@context": "https://schema.org",
    "@type": "DefinedTerm",
    "name": term.title,
    "description": term.summary,
    "url": pageUrl,
    "inDefinedTermSet": `${siteUrl}/glosarium`
  }

  return (
    <div className="flex min-h-screen w-full overflow-x-clip bg-background">
      <SidebarLeft />
      <div className="flex min-h-screen min-w-0 flex-1 flex-col w-full lg:ml-[270px]">
        <TopBar />
        <div className="mx-auto flex w-full max-w-[1400px] flex-1 items-stretch gap-5 px-0 py-0 md:px-4 md:py-5">
          <main className="min-w-0 w-full flex-1" id="main-content" role="main">
            
            <script
              type="application/ld+json"
              dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
            />

            <article className="rounded-none bg-card p-6 md:rounded-2xl md:border md:border-border md:p-8 lg:p-12 shadow-sm">
              <nav aria-label="Breadcrumb" className="mb-6 flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
                <Link href="/" prefetch={false} className="hover:text-primary hover:underline transition-colors">
                  Home
                </Link>
                <span>›</span>
                <Link href="/glosarium" prefetch={false} className="hover:text-primary hover:underline transition-colors">
                  Glosarium
                </Link>
                <span>›</span>
                <span className="text-foreground font-medium">{term.title}</span>
              </nav>

              <header className="mb-8 border-b border-border pb-6">
                <div className="flex items-center gap-3 mb-2">
                  <span className="rounded-full bg-blue-100 dark:bg-blue-900/40 px-3 py-1 text-xs font-semibold text-blue-700 dark:text-blue-400 uppercase tracking-wider">
                    Definisi
                  </span>
                </div>
                <h1 className="font-serif text-3xl font-bold text-foreground md:text-4xl lg:text-5xl leading-tight">
                  {term.title}
                </h1>
              </header>

              <div className="prose prose-lg dark:prose-invert max-w-none text-foreground/90 leading-relaxed">
                <p className="text-lg md:text-xl font-medium text-foreground mb-6 leading-relaxed">
                  <strong className="text-blue-600 dark:text-blue-400">{term.title}</strong> adalah {term.summary.charAt(0).toLowerCase() + term.summary.slice(1)}
                </p>
              </div>
            </article>
          </main>
          <SidebarRight />
        </div>
        <Footer />
      </div>
      <ScrollToTop />
    </div>
  )
}

Tahap Akhir: Push ke GitHub dan Deploy ke Publik

Setelah semua langkah selesai dan kode berjalan dengan baik, tahap terakhir adalah mengunggah kode proyek Anda ke GitHub. Karena proyek ini menggunakan Cloudflare Pages, sistem akan otomatis memproses peluncuran fitur ini ke website Anda secara instan.

Buka terminal Anda dan jalankan rangkaian perintah Git berikut:

git add .
git commit -m "Peluncuran fitur Smartlink Glosarium"
git push origin main

Hanya dalam waktu 1-3 menit, fitur Smart Link dengan database Glosarium Anda akan aktif dan bisa digunakan langsung oleh pengunjung website.

Mengintegrasikan sebuah fitur edukatif yang dinamis ke dalam situs web ternyata tidak serumit yang dibayangkan, asalkan fondasi datanya disusun dengan benar. Perpaduan antara ketangguhan Next.js di frontend, keandalan Prisma ORM dalam mengelola relasi data, serta efisiensi database Cloudflare D1 terbukti berhasil melahirkan sistem Glosarium interaktif yang bebas hambatan.

Melalui langkah instalasi awal dan penyusunan dua belas kode di atas, Anda kini tidak hanya memiliki kamus istilah standar, namun sebuah ekosistem Smart Tooltip cerdas yang mampu memanjakan pembaca, mempertahankan keterlibatan pengunjung (retention rate), dan menjamin performa indeks pencarian Google (SEO) tetap prima. Selamat mengembangkan proyek Anda ke tingkat selanjutnya!

Artikel Terkait