Beranda > Blog > Panduan Lengkap Implementasi Nuxt Content 3: Dari Setup Hingga Related Article

Panduan Lengkap Implementasi Nuxt Content 3: Dari Setup Hingga Related Article

Panduan Lengkap Implementasi Nuxt Content 3: Dari Setup Hingga Related Article

Pelajari cara mengimplementasikan Nuxt Content 3 secara lengkap, dari setup awal, konfigurasi, pembuatan slug, hingga implementasi related article dan internal linking.

Key Takeaways

  1. Nuxt Content 3 adalah modul dokumentasi untuk Nuxt 3 yang memudahkan pengelolaan konten berbasis file.
  2. Setup dan Konfigurasi meliputi instalasi, pengaturan di nuxt.config.ts, dan konfigurasi content.config.ts.
  3. Struktur Folder yang tepat pada content/blog memastikan organisasi konten yang baik.
  4. Front Matter berfungsi untuk menambahkan metadata pada konten seperti title, date, dan tags.
  5. Slugs memungkinkan URL yang mudah dibaca dan dinamis untuk setiap konten.
  6. Implementasi Related Article meningkatkan internal linking dan user experience.
  7. 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> &gt; 
      <a href="/blog" class="text-green-600 hover:underline">Blog</a> &gt; 
      <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">
        &larr; 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

Beberapa error umum yang mungkin Anda temui saat mengimplementasikan related articles dan solusinya:

  1. 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>
    
  2. 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()
    )
    
  3. 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()
    
  4. 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}`
    }
    

Jika link tidak berfungsi dengan benar, pastikan:

  1. Anda menggunakan NuxtLink untuk navigasi internal:
    <NuxtLink :to="getPostUrl(article)">{{ article.title }}</NuxtLink>
    
  2. 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:

  1. Pastikan struktur front matter di artikel Markdown sesuai dengan skema di content.config.ts
  2. Periksa konsol untuk error validasi Zod
  3. 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:

  1. Setup dan konfigurasi Nuxt Content
  2. Struktur konten markdown dengan front matter
  3. Implementasi halaman slug untuk artikel individual
  4. Pengelompokan artikel berdasarkan tag
  5. 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!

Referensi