Key Takeaways
- Nuxt Content 3 adalah modul dokumentasi untuk Nuxt 3 yang memudahkan pengelolaan konten berbasis file.
- Setup dan Konfigurasi meliputi instalasi, pengaturan di nuxt.config.ts, dan konfigurasi content.config.ts.
- Struktur Folder yang tepat pada content/blog memastikan organisasi konten yang baik.
- Front Matter berfungsi untuk menambahkan metadata pada konten seperti title, date, dan tags.
- Slugs memungkinkan URL yang mudah dibaca dan dinamis untuk setiap konten.
- Implementasi Related Article meningkatkan internal linking dan user experience.
- Pemecahan Masalah membantu menyelesaikan error umum dalam implementasi Nuxt Content.
Pendahuluan
Nuxt Content 3 adalah modul dokumentasi yang kuat untuk framework Nuxt 3, memungkinkan Anda membuat dan mengelola konten berbasis file dengan mudah. Modul ini sangat ideal untuk blog, dokumentasi, atau situs web dengan konten yang sering diperbarui.
Dalam panduan lengkap ini, kita akan menjelaskan langkah demi langkah bagaimana mengimplementasikan Nuxt Content 3, mulai dari setup awal hingga fitur-fitur lanjutan seperti related article dan internal linking. Panduan ini disusun berdasarkan pengalaman nyata implementasi Nuxt Content 3.4 dan solusi untuk masalah umum yang mungkin Anda hadapi.
Instalasi dan Setup Awal
1. Instalasi Nuxt Content
Langkah pertama adalah menginstal Nuxt Content 3 pada project Nuxt 3 yang sudah ada. Buka terminal dan jalankan perintah berikut:
npm install @nuxt/content
# atau dengan yarn
yarn add @nuxt/content
# atau dengan pnpm
pnpm add @nuxt/content
2. Aktivasi Modul di nuxt.config.ts
Setelah menginstal, Anda perlu menambahkan modul ke konfigurasi Nuxt Anda. Buka file nuxt.config.ts
dan tambahkan konfigurasi berikut:
// nuxt.config.ts
export default defineNuxtConfig({
// Modules yang digunakan
modules: [
'@nuxtjs/sitemap',
'@nuxt/content',
'@nuxtjs/tailwindcss',
'@nuxt/icon',
'@vueuse/nuxt',
'@nuxt/image',
],
// Plugin yang digunakan
plugins: [
'~/plugins/analytics.client.ts'
],
sitemap: {
// Base configuration relying on content module instead
siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://roofel.com',
},
// Konfigurasi Nuxt Content
content: {
documentDriven: true,
markdown: {
toc: {
depth: 3,
searchDepth: 3,
},
},
highlight: {
theme: 'github-dark', // Tema Shiki, bisa diganti (contoh: 'nord', 'material-theme')
preload: ['html', 'css', 'javascript'] // Bahasa yang akan di-highlight
}
},
})
Konfigurasi Content dengan content.config.ts
Untuk mendefinisikan skema konten dan validasi, Anda dapat membuat file content.config.ts
di root project. Ini sangat berguna untuk memastikan struktur konten Anda konsisten.
Contoh Konfigurasi dengan Zod
// content.config.ts
import { defineCollection, defineContentConfig } from '@nuxt/content'
import { asSitemapCollection } from '@nuxtjs/sitemap/content'
import { z } from 'zod' // Using directly from zod instead of from content
export default defineContentConfig({
collections: {
posts: defineCollection(
asSitemapCollection({
type: 'page',
source: 'blog/**/*',
schema: z.object({
title: z.string().nonempty(),
description: z.string().nonempty(),
image: z.object({ src: z.string().nonempty() }),
authors: z.array(
z.object({
name: z.string().nonempty(),
to: z.string().nonempty(),
avatar: z.object({ src: z.string().nonempty() })
})
),
date: z.date(),
badge: z.object({ label: z.string().nonempty() }).optional(),
tags: z.array(z.string()).optional(),
popular: z.boolean().optional(),
// Properti untuk sitemap
robots: z.object({
index: z.boolean().optional(),
follow: z.boolean().optional()
}).optional(),
sitemap: z.object({
priority: z.number().min(0).max(1).optional().default(0.7),
changefreq: z.enum(['always', 'hourly', 'daily', 'weekly', 'monthly', 'yearly', 'never']).optional().default('weekly')
}).optional()
})
})
)
}
})
Struktur Folder dan Konten
1. Struktur Folder
Buat struktur folder berikut untuk konten blog Anda:
content/
├── blog/
│ ├── artikel-pertama.md
│ ├── artikel-kedua.md
│ ├── kategori-a/
│ │ ├── artikel-terkait-a.md
│ │ └── ...
│ └── ...
└── components.ts (opsional, untuk komponen global)
2. Format Konten dengan Front Matter
Konten Markdown dalam Nuxt Content menggunakan front matter untuk metadata. Berikut contoh artikel sederhana:
---
title: 'Judul Artikel Saya'
date: 2025-04-25
description: 'Deskripsi singkat tentang artikel ini'
image:
src: 'https://example.com/gambar-artikel.webp'
authors:
- name: 'Nama Penulis'
to: '/blog/authors/nama-penulis'
avatar:
src: '/avatar-penulis.png'
tags: [tag1, tag2, tag3]
popular: true
---
## Judul Bagian Pertama
Konten artikel saya dimulai di sini. Ini adalah paragraf pertama.
## Judul Bagian Kedua
Ini adalah paragraf dalam bagian kedua dari artikel.
### Sub-bagian
Ini adalah sub-bagian dengan konten lebih spesifik.
3. Komponen Kustom dalam Konten
Anda dapat menggunakan komponen kustom dalam konten Markdown. Definisikan di content/components.ts
:
// content/components.ts
export default defineContentComponents({
// Daftar komponen yang digunakan dalam konten
})
Kemudian gunakan dalam konten Markdown:
::alert{type="info"}
Ini adalah kotak informasi menggunakan komponen alert.
::
Implementasi Routing dengan Slug
1. Membuat Halaman Slug untuk Blog
Buat file pages/blog/[slug].vue
untuk menampilkan artikel individual:
<template>
<main
v-if="post"
class="container mx-auto px-4 py-8"
itemscope
itemtype="https://schema.org/BlogPosting"
>
<!-- Breadcrumb -->
<div class="mb-4 mt-6">
<a href="/" class="text-green-600 hover:underline">Beranda</a> >
<a href="/blog" class="text-green-600 hover:underline">Blog</a> >
<span class="text-gray-600">{{ post.title }}</span>
</div>
<!-- Article Header -->
<header class="mb-8">
<h1 class="text-3xl md:text-4xl font-bold mb-4" itemprop="headline">{{ post.title }}</h1>
<!-- Metadata artikel (tanggal, penulis, dll) -->
<div class="flex items-center mb-6">
<time class="text-gray-600 text-sm" itemprop="datePublished" :datetime="post.date">
{{ new Date(post.date).toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' }) }}
</time>
<span class="mx-2 text-gray-300">•</span>
<div v-if="post.authors && post.authors.length" class="flex items-center">
<img
v-if="post.authors[0].avatar?.src"
:src="post.authors[0].avatar.src"
:alt="post.authors[0].name"
class="w-6 h-6 rounded-full mr-2"
/>
<a
v-if="post.authors[0].to"
:href="post.authors[0].to"
class="text-gray-600 text-sm hover:text-blue-600"
>
{{ post.authors[0].name }}
</a>
</div>
</div>
<!-- Tags -->
<div v-if="post.tags && post.tags.length" class="flex flex-wrap gap-2 mb-6">
<a
v-for="tag in post.tags"
:key="tag"
:href="`/blog/tags/${tag.toLowerCase()}`"
class="bg-gray-100 text-gray-800 text-sm px-3 py-1 rounded-full hover:bg-blue-100"
>
#{{ tag }}
</a>
</div>
<!-- Featured Image -->
<div class="mb-8">
<img
v-if="post.image?.src"
:src="post.image.src"
:alt="post.title"
class="w-full h-auto max-h-[500px] object-cover rounded-lg shadow-md"
loading="lazy"
itemprop="image"
/>
</div>
<p class="text-xl text-gray-600 mb-8 max-w-3xl" itemprop="description">{{ post.description }}</p>
</header>
<!-- Article Content -->
<div class="flex flex-col lg:flex-row gap-8">
<!-- Main Content -->
<article class="prose max-w-none lg:w-2/3 mb-12" itemprop="articleBody">
<ContentRenderer :value="post" />
</article>
<!-- Sidebar (TOC, etc) -->
<aside class="lg:w-1/3 order-first lg:order-last mb-8 lg:mb-0">
<!-- Table of Contents component bisa ditambahkan di sini -->
</aside>
</div>
<!-- Related Articles Section (akan diimplementasikan nanti) -->
</main>
<!-- Loading State -->
<div v-else class="container mx-auto px-4 py-8 text-center">
<p class="text-gray-600">Memuat artikel...</p>
</div>
</template>
<script setup lang="ts">
import { useRoute, useAsyncData, queryCollection, ref } from '#imports'
const route = useRoute()
// Fetch current post
const { data: post } = await useAsyncData(route.path, () => queryCollection('posts').path(route.path).first())
if (!post.value) {
throw createError({ statusCode: 404, statusMessage: 'Post not found', fatal: true })
}
// SEO metadata
useHead({
title: post.value.title,
meta: [
{ name: 'description', content: post.value.description },
{ property: 'og:title', content: post.value.title },
{ property: 'og:description', content: post.value.description },
{ property: 'og:type', content: 'article' },
{ property: 'og:image', content: post.value.image?.src || '' }
],
link: [
{ rel: 'canonical', href: `https://example.com${route.path}` }
]
})
</script>
<style scoped>
/* Styling untuk artikel */
.prose {
max-width: 100%;
}
.prose img {
border-radius: 0.5rem;
margin: 1.5rem 0;
}
.prose a {
color: #2563eb;
text-decoration: none;
}
.prose a:hover {
text-decoration: underline;
}
</style>
2. Membuat Halaman Index untuk Blog
Buat file pages/blog/index.vue
untuk daftar artikel:
<template>
<main class="container mx-auto py-12 px-4">
<h1 class="text-3xl font-bold mb-8 text-center">Blog</h1>
<!-- Blog Grid -->
<div v-if="posts.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="post in posts"
:key="post._path"
class="bg-white rounded-lg shadow-md overflow-hidden"
>
<!-- Post Image -->
<div v-if="post.image?.src" class="h-48 overflow-hidden">
<img
:src="post.image.src"
:alt="post.title"
class="w-full h-full object-cover transition-transform hover:scale-105"
/>
</div>
<div class="p-5">
<!-- Tags -->
<div v-if="post.tags && post.tags.length" class="flex flex-wrap gap-2 mb-3">
<a
v-for="tag in post.tags.slice(0, 2)"
:key="tag"
:href="`/blog/tags/${tag.toLowerCase()}`"
class="text-xs bg-gray-100 text-gray-800 px-2 py-1 rounded-full"
>
#{{ tag }}
</a>
</div>
<!-- Title -->
<h2 class="text-xl font-semibold mb-3">
<NuxtLink :to="post._path" class="text-gray-900 hover:text-blue-600">
{{ post.title }}
</NuxtLink>
</h2>
<!-- Description -->
<p class="text-gray-600 mb-4 line-clamp-2">{{ post.description }}</p>
<!-- Meta info -->
<div class="flex items-center justify-between text-sm text-gray-500">
<div class="flex items-center">
<img
v-if="post.authors && post.authors.length && post.authors[0].avatar?.src"
:src="post.authors[0].avatar.src"
:alt="post.authors[0].name"
class="w-6 h-6 rounded-full mr-2"
/>
<span v-if="post.authors && post.authors.length">{{ post.authors[0].name }}</span>
</div>
<time :datetime="post.date">
{{ formatDate(post.date) }}
</time>
</div>
</div>
</div>
</div>
<!-- No Posts -->
<div v-else class="text-center py-12">
<p class="text-gray-600">Belum ada artikel tersedia.</p>
</div>
</main>
</template>
<script setup>
import { useAsyncData, queryCollection } from '#imports'
// Fetch all posts
const { data: posts } = await useAsyncData('posts', async () => {
const fetchedPosts = await queryCollection('posts').all()
// Sort by date (descending)
return fetchedPosts.sort((a, b) => new Date(b.date) - new Date(a.date))
})
// Format date
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' })
}
// SEO metadata
useHead({
title: 'Blog',
meta: [
{ name: 'description', content: 'Artikel dan insights terbaru dari kami' }
]
})
</script>
3. Membuat Halaman Tags
Buat file pages/blog/tags/[tag].vue
untuk menampilkan artikel berdasarkan tag:
<template>
<main class="container mx-auto py-12 px-4">
<h1 class="text-3xl font-bold mb-2 text-center">
Artikel dengan tag "#{{ decodedTag }}"
</h1>
<p class="text-center text-gray-600 mb-8">{{ filteredPosts.length }} artikel ditemukan</p>
<!-- Posts Grid -->
<div v-if="filteredPosts.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="post in filteredPosts"
:key="post._path"
class="bg-white rounded-lg shadow-md overflow-hidden"
>
<!-- Post content similar to index.vue -->
<div class="p-5">
<h2 class="text-xl font-semibold mb-3">
<NuxtLink :to="post._path" class="text-gray-900 hover:text-blue-600">
{{ post.title }}
</NuxtLink>
</h2>
<p class="text-gray-600 mb-4 line-clamp-2">{{ post.description }}</p>
<div class="flex justify-between text-sm text-gray-500">
<span v-if="post.authors && post.authors.length">{{ post.authors[0].name }}</span>
<time :datetime="post.date">
{{ formatDate(post.date) }}
</time>
</div>
</div>
</div>
</div>
<!-- No Posts -->
<div v-else class="text-center py-12">
<p class="text-gray-600">Tidak ada artikel dengan tag "{{ decodedTag }}".</p>
</div>
<!-- Back Link -->
<div class="text-center mt-8">
<NuxtLink to="/blog" class="text-blue-600 hover:underline">
← Kembali ke Blog
</NuxtLink>
</div>
</main>
</template>
<script setup>
import { useRoute, useAsyncData, queryCollection, computed } from '#imports'
// Get route params
const route = useRoute()
const tag = route.params.tag
const decodedTag = decodeURIComponent(tag)
// Fetch all posts
const { data: posts } = await useAsyncData(`tag-${tag}`, () => queryCollection('posts').all())
// Filter posts by tag
const filteredPosts = computed(() => {
if (!posts.value) return []
return posts.value.filter(post => {
return post.tags && Array.isArray(post.tags) &&
post.tags.some(t => t.toLowerCase() === decodedTag.toLowerCase())
}).sort((a, b) => new Date(b.date) - new Date(a.date))
})
// Format date
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('id-ID', { year: 'numeric', month: 'long', day: 'numeric' })
}
// SEO metadata
useHead({
title: `Tag: ${decodedTag}`,
meta: [
{ name: 'description', content: `Artikel dengan tag ${decodedTag}` }
]
})
</script>
Implementasi Related Article
Bagian ini adalah salah satu yang paling berguna untuk meningkatkan internal linking dan user experience. Berikut cara mengimplementasikan related article pada halaman slug.vue:
1. Menambahkan Logika Related Article
Tambahkan kode berikut di bagian script pada file pages/blog/[slug].vue
:
// Helper function untuk mendapatkan URL post
function getPostUrl(post) {
// Coba semua kemungkinan properti path
if (post.path) return post.path
if (post._path) return post._path
// Fallback ke slug dari _path atau _file
const slug = post._path?.split('/').pop() ||
post._file?.split('/').pop()?.replace('.md', '') ||
post._id?.split(':').pop()
return `/blog/${slug}`
}
// Helper function untuk mendapatkan URL post
function getPostUrl(post) {
// Coba semua kemungkinan properti path
if (post.path) return post.path
if (post._path) return post._path
// Fallback ke slug dari _path atau _file
const slug = post._path?.split('/').pop() ||
post._file?.split('/').pop()?.replace('.md', '') ||
post._id?.split(':').pop()
return `/blog/${slug}`
}
// Fetch related posts based on tags
const { data: related } = await useAsyncData(`${route.path}-related`, async () => {
// Pastikan post ada dan memiliki tags
if (!post.value || !post.value.tags) return []
try {
// Konversi tag ke lowercase untuk perbandingan case-insensitive
const currentTags = post.value.tags.map(tag =>
typeof tag === 'string' ? tag.toLowerCase() : String(tag).toLowerCase()
)
if (!currentTags.length) return []
// Ambil semua artikel
const posts = await queryCollection('posts').all()
// Filter artikel untuk menghilangkan artikel saat ini dan menemukan yang terkait
const relatedPosts = posts
.filter(article => {
// Skip artikel saat ini
if (article._path === route.path) return false
// Skip artikel tanpa tags
if (!article.tags || !Array.isArray(article.tags) || !article.tags.length) return false
// Konversi tag artikel ke lowercase
const articleTags = article.tags.map(tag =>
typeof tag === 'string' ? tag.toLowerCase() : String(tag).toLowerCase()
)
// Periksa apakah ada tag yang cocok
return articleTags.some(tag => currentTags.includes(tag))
})
.slice(0, 4) // Maksimal 4 artikel terkait
return relatedPosts
} catch (error) {
console.error('Error fetching related posts:', error)
return []
}
}, {
default: () => []
})
2. Menambahkan Template Related Article
Tambahkan kode berikut di bagian template, sebelum penutup tag </main>
:
<!-- Related Articles Section -->
<div v-if="related && related.length > 0" class="max-w-3xl mx-auto mt-16 mb-12">
<div class="border-t border-gray-200 pt-12">
<h2 class="text-2xl font-bold mb-6 text-gray-800">Artikel Terkait</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="article in related"
:key="article._path || article._id"
class="bg-white border border-gray-200 rounded-lg p-5 transition-all hover:shadow-md hover:border-gray-300 flex flex-col h-full"
>
<NuxtLink
:to="getPostUrl(article)"
class="block h-full no-underline"
>
<h3 class="text-lg font-semibold mb-3 text-green-600 hover:text-green-700 transition-colors">
{{ article.title }}
</h3>
<p v-if="article.description" class="text-sm text-gray-600 line-clamp-2 mb-4">
{{ article.description }}
</p>
<div class="mt-auto pt-3 flex items-center text-xs text-gray-500">
<time v-if="article.date">
{{ new Date(article.date).toLocaleDateString('id-ID', { year: 'numeric', month: 'short', day: 'numeric' }) }}
</time>
<span v-if="article.tags && article.tags.length" class="ml-2 flex">
<span class="bg-gray-100 text-gray-600 rounded-full px-2 py-0.5">
#{{ article.tags[0] }}
</span>
</span>
</div>
</NuxtLink>
</div>
</div>
</div>
</div>
Pemecahan Masalah Umum
1. Error pada Related Articles
Beberapa error umum yang mungkin Anda temui saat mengimplementasikan related articles dan solusinya:
- Error "Cannot read properties of null (reading 'length')":
- Terjadi ketika mencoba mengakses properti dari objek yang null atau undefined
- Solusi: Tambahkan pemeriksaan dengan v-if pada template
<div v-if="related && related.length > 0"> <!-- konten related articles --> </div>
- Error "Cannot read properties of undefined (reading 'toUpperCase')":
- Terjadi pada pemrosesan tag yang mungkin tidak dalam format yang diharapkan
- Solusi: Konversi tag ke string dan lowercase untuk penanganan yang lebih aman
const currentTags = post.value.tags.map(tag => typeof tag === 'string' ? tag.toLowerCase() : String(tag).toLowerCase() )
- Error "(...).find is not a function":
- Terjadi karena dalam Nuxt Content 3.4, metode yang benar adalah .all() bukan .find()
- Solusi: Gunakan metode .all() untuk mendapatkan semua dokumen
const posts = await queryCollection('posts').all()
- Tidak ada artikel terkait yang muncul meski ada artikel dengan tag yang sama:
- Periksa apakah tag-handling dilakukan secara case-insensitive
- Pastikan fungsi getPostUrl() menangani semua format path dengan benar
function getPostUrl(post) { if (post.path) return post.path if (post._path) return post._path const slug = post._path?.split('/').pop() || post._file?.split('/').pop()?.replace('.md', '') || post._id?.split(':').pop() return `/blog/${slug}` }
2. Link yang Tidak Berfungsi
Jika link tidak berfungsi dengan benar, pastikan:
- Anda menggunakan
NuxtLink
untuk navigasi internal:<NuxtLink :to="getPostUrl(article)">{{ article.title }}</NuxtLink>
- Fungsi
getPostUrl
menangani semua kemungkinan format path:function getPostUrl(post) { // Coba semua kemungkinan properti path if (post.path) return post.path if (post._path) return post._path // Fallback ke slug dari _path atau _file const slug = post._path?.split('/').pop() || post._file?.split('/').pop()?.replace('.md', '') || post._id?.split(':').pop() return `/blog/${slug}` }
3. Memastikan Validasi Content Berhasil
Jika konten tidak tampil atau validasi gagal:
- Pastikan struktur front matter di artikel Markdown sesuai dengan skema di
content.config.ts
- Periksa konsol untuk error validasi Zod
- Pastikan nilai tanggal dalam format yang benar (YYYY-MM-DD)
Kesimpulan
Implementasi Nuxt Content 3 memberikan cara yang powerful dan fleksibel untuk mengelola konten berbasis file di website Nuxt. Dengan mengikuti panduan ini, Anda dapat mengatur blog dengan fitur lengkap, termasuk:
- Setup dan konfigurasi Nuxt Content
- Struktur konten markdown dengan front matter
- Implementasi halaman slug untuk artikel individual
- Pengelompokan artikel berdasarkan tag
- Fitur related article untuk meningkatkan internal linking
Seluruh implementasi ini meningkatkan SEO dan user experience, memudahkan pengguna menemukan konten terkait, dan membantu mesin pencari mengindeks konten Anda dengan lebih efektif.
Jika Anda mengalami masalah, selalu periksa dokumentasi resmi Nuxt Content atau forum komunitas Nuxt untuk bantuan lebih lanjut. Happy coding!