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

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:
-
Install Prisma:
npm install prisma --save-dev -
Inisialisasi Prisma: Perintah ini akan membuat folder
prismadan fileschema.prisma.npx prisma init -
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 -
Generate Klien Prisma: Perintah ini berfungsi menjembatani kode Next.js Anda agar bisa membaca skema database yang baru saja dibuat.
npx prisma generate -
Membuat File Migrasi SQL: Perintah ini akan menyuruh Cloudflare D1 menyiapkan instruksi pembuatan tabel baru (ganti
db-penajika nama database Anda berbeda).npx wrangler d1 migrations create db-pena buat_tabel_glosarium -
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 -
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"
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 });
}
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}
)}
);
}
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 });
}
}
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!


