跳至主要內容

Nuxt Content 打造靜態部落格:從設定到部署

Nuxt Content 打造靜態部落格:從設定到部署

有了 Nuxt Content,你不需要資料庫、不需要後端 API,只需要 Markdown 檔案和 Nuxt,就能打造一個功能完整、效能優異的靜態部落格。這篇文章帶你從零開始,建立一個包含文章列表、標籤過濾、全文搜尋和 RSS feed 的完整部落格。

初始化專案

npx nuxi@latest init my-blog
cd my-blog
npx nuxi module add content
npm install

目錄結構:

my-blog/
├── content/
│   ├── articles/
│   │   ├── hello-world.md
│   │   └── vue-tips.md
│   └── pages/
│       └── about.md
├── components/
├── pages/
│   ├── index.vue
│   ├── blog/
│   │   ├── index.vue
│   │   └── [slug].vue
│   └── tags/
│       └── [tag].vue
└── nuxt.config.ts

設定 nuxt.config.ts

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/content'],

  content: {
    highlight: {
      theme: {
        default: 'github-light',
        dark: 'github-dark'
      },
      langs: ['javascript', 'typescript', 'vue', 'python', 'bash', 'json', 'yaml']
    },
    markdown: {
      toc: {
        depth: 3,
        searchDepth: 3
      }
    }
  }
})

撰寫 Markdown 文章

---
title: 我的第一篇文章
description: 這是文章的摘要,會顯示在列表頁
date: 2026-02-13
tags: [vue, nuxt, frontend]
image: /images/article-cover.jpg
draft: false
---

# 正文從這裡開始

這是文章內容,支援完整的 Markdown 語法。

## 程式碼高亮

```javascript
const message = 'Hello, Nuxt Content!'
console.log(message)

支援自訂元件(MDC 語法)

::callout{type="info"} 這是一個提示框元件 ::


## 文章列表頁

```vue
<!-- pages/blog/index.vue -->
<template>
  <div class="blog-index">
    <h1>所有文章</h1>

    <!-- 標籤過濾 -->
    <div class="tag-filter">
      <button
        :class="{ active: !selectedTag }"
        @click="selectedTag = null"
      >
        全部
      </button>
      <button
        v-for="tag in allTags"
        :key="tag"
        :class="{ active: selectedTag === tag }"
        @click="selectedTag = tag"
      >
        {{ tag }}
      </button>
    </div>

    <!-- 搜尋框 -->
    <input
      v-model="searchQuery"
      type="search"
      placeholder="搜尋文章..."
      class="search-input"
    />

    <!-- 文章列表 -->
    <div class="articles-grid">
      <NuxtLink
        v-for="article in filteredArticles"
        :key="article._path"
        :to="article._path"
        class="article-card"
      >
        <img
          v-if="article.image"
          :src="article.image"
          :alt="article.title"
        />
        <div class="article-info">
          <div class="tags">
            <span v-for="tag in article.tags" :key="tag" class="tag">
              {{ tag }}
            </span>
          </div>
          <h2>{{ article.title }}</h2>
          <p>{{ article.description }}</p>
          <time :datetime="article.date">
            {{ formatDate(article.date) }}
          </time>
        </div>
      </NuxtLink>
    </div>
  </div>
</template>

<script setup lang="ts">
const selectedTag = ref<string | null>(null)
const searchQuery = ref('')

// 取得所有文章(排除草稿)
const { data: articles } = await useAsyncData('articles', () =>
  queryContent('/articles')
    .where({ draft: { $ne: true } })
    .sort({ date: -1 })
    .find()
)

// 取得所有標籤
const allTags = computed(() => {
  const tagSet = new Set<string>()
  articles.value?.forEach(article => {
    article.tags?.forEach((tag: string) => tagSet.add(tag))
  })
  return Array.from(tagSet).sort()
})

// 過濾文章
const filteredArticles = computed(() => {
  return articles.value?.filter(article => {
    const matchesTag = !selectedTag.value || article.tags?.includes(selectedTag.value)
    const matchesSearch = !searchQuery.value ||
      article.title.toLowerCase().includes(searchQuery.value.toLowerCase()) ||
      article.description?.toLowerCase().includes(searchQuery.value.toLowerCase())
    return matchesTag && matchesSearch
  }) ?? []
})

function formatDate(dateStr: string) {
  return new Date(dateStr).toLocaleDateString('zh-TW', {
    year: 'numeric',
    month: 'long',
    day: 'numeric'
  })
}

// SEO
useSeoMeta({
  title: '部落格文章',
  description: '分享技術文章與開發心得'
})
</script>

文章詳情頁

<!-- pages/blog/[slug].vue -->
<template>
  <article class="article">
    <!-- 封面圖 -->
    <img
      v-if="article.image"
      :src="article.image"
      :alt="article.title"
      class="cover-image"
    />

    <header class="article-header">
      <div class="tags">
        <NuxtLink
          v-for="tag in article.tags"
          :key="tag"
          :to="`/tags/${tag}`"
          class="tag"
        >
          {{ tag }}
        </NuxtLink>
      </div>
      <h1>{{ article.title }}</h1>
      <time :datetime="article.date">{{ formatDate(article.date) }}</time>
    </header>

    <!-- 目錄 -->
    <nav v-if="article.body?.toc?.links?.length" class="toc">
      <h2>目錄</h2>
      <ul>
        <li v-for="link in article.body.toc.links" :key="link.id">
          <a :href="`#${link.id}`">{{ link.text }}</a>
          <ul v-if="link.children">
            <li v-for="child in link.children" :key="child.id">
              <a :href="`#${child.id}`">{{ child.text }}</a>
            </li>
          </ul>
        </li>
      </ul>
    </nav>

    <!-- 文章內容 -->
    <div class="article-body">
      <ContentRenderer :value="article" />
    </div>

    <!-- 上/下一篇 -->
    <nav class="article-nav">
      <NuxtLink v-if="prev" :to="prev._path" class="prev">
        ← {{ prev.title }}
      </NuxtLink>
      <NuxtLink v-if="next" :to="next._path" class="next">
        {{ next.title }} →
      </NuxtLink>
    </nav>
  </article>
</template>

<script setup lang="ts">
const route = useRoute()

const { data: article } = await useAsyncData(`article-${route.params.slug}`, () =>
  queryContent(`/articles/${route.params.slug}`).findOne()
)

if (!article.value) {
  throw createError({ statusCode: 404, message: '文章不存在' })
}

// 取得前後文章
const [{ data: prev }, { data: next }] = await Promise.all([
  useAsyncData(`prev-${route.params.slug}`, () =>
    queryContent('/articles')
      .where({ draft: { $ne: true }, date: { $lt: article.value!.date } })
      .sort({ date: -1 })
      .limit(1)
      .findOne()
  ),
  useAsyncData(`next-${route.params.slug}`, () =>
    queryContent('/articles')
      .where({ draft: { $ne: true }, date: { $gt: article.value!.date } })
      .sort({ date: 1 })
      .limit(1)
      .findOne()
  )
])

// SEO
useSeoMeta({
  title: article.value.title,
  description: article.value.description,
  ogImage: article.value.image
})
</script>

生成靜態站台

# 預覽
npm run dev

# 建置靜態檔案
npm run generate

# 預覽靜態建置結果
npm run preview

nuxt.config.ts 中設定靜態生成:

export default defineNuxtConfig({
  nitro: {
    prerender: {
      routes: ['/sitemap.xml', '/rss.xml']
    }
  }
})

部署到 Cloudflare Pages

# 建置並部署
npx wrangler pages deploy .output/public

或透過 Git 連結 Cloudflare Pages:

  • Build command: npm run generate
  • Build output directory: .output/public

總結

Nuxt Content 提供了一個優雅的方式來建立 Markdown 驅動的靜態部落格。它的 queryContent API 直覺好用,搭配 Nuxt 3 的 useAsyncData 和 SSG 功能,你可以打造一個零運行成本、高效能的個人部落格。內容版控在 Git,部署在全球 CDN,這就是現代靜態部落格的最佳形態。

分享這篇文章