ホーム / 記事一覧 /nuxt-content v3でブログを構築してみた

nuxt-content v3でブログを構築してみた

2025/3/23
11 分で読めます
nuxt3nuxt-content

はじめに

nuxt-content v3でTailwindCSSを使ったブログ構築方法について解説します。

今回は、Claude 3.7 sonnetにベースの構築や機能追加をしてもらい実装しました。 いわゆるバイブコーディングだけでは、うまく構築できなかったため、 適宜、手で直しながら進めていきました。

バージョン情報

パッケージバージョン
Nuxtv3.15.4
Nuxt Contentv3.2.2
TailwindCSSv3.4.17
ESLintv8.57.1
Markuplintv4.11.7

目次

  1. プロジェクトの初期化
  2. Nuxt ContentとSSRの設定
  3. TailwindCSSの導入
  4. Linterの設定(ESLint, Markuplint)
  5. ブログの基本構造
  6. 実装例
  7. コードのリファクタリング
  8. デプロイ

1. プロジェクトの初期化

まず、Nuxtプロジェクトを作成します。

npx nuxi init my-blog
cd my-blog
npm install

2. Nuxt ContentとSSRの設定

設定の確認

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
  // その他の設定
})

Nuxt Contentのインストール

ブログコンテンツの管理に便利な@nuxt/contentをインストールします。

npm install @nuxt/content

nuxt.config.tsに追加:

export default defineNuxtConfig({
  ssr: true,
  modules: [
    '@nuxt/content'
  ],
  content: {
    // nuxt-contentの好みの設定を入れて下さい
  },
})

3. TailwindCSSの導入

インストール

npm install -D tailwindcss postcss autoprefixer @nuxtjs/tailwindcss @tailwindcss/typography

設定ファイルの作成

// nuxt.config.ts
export default defineNuxtConfig({
  ssr: true,
  modules: [
    '@nuxt/content',
    '@nuxtjs/tailwindcss'
  ],
  content: {
    // nuxt-contentの好みの設定を入れて下さい
  }
})

tailwind.config.jsでテーマ変数を定義し、カスタマイズします:

// tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./components/**/*.{js,vue,ts}",
    "./layouts/**/*.vue",
    "./pages/**/*.vue",
    "./plugins/**/*.{js,ts}",
    "./nuxt.config.{js,ts}",
    "./app.vue"
  ],
  theme: {
    extend: {
      colors: {
        // プライマリーカラーの定義
        primary: {
          50: '#f0f9ff',
          100: '#e0f2fe',
          // ...他の色階調
          900: '#0c4a6e',
          950: '#082f49',
        },
        // セカンダリーカラー
        secondary: {
          // ...色の定義
        },
        // アクセントカラー
        accent: {
          // ...色の定義
        },
      },
      fontFamily: {
        sans: ['"Noto Sans JP"', 'sans-serif'],
        serif: ['"Noto Serif JP"', 'serif'],
        mono: ['"JetBrains Mono"', 'monospace'],
      },
      fontSize: {
        // カスタムフォントサイズ
      },
      spacing: {
        container: '1440px',
      },
      borderRadius: {
        card: '0.75rem',
      }
    },
  },
  plugins: [
    require('@tailwindcss/typography'),
  ],
}

CSSファイルの作成

assets/css/tailwind.cssを作成し、カスタムスタイルを追加します:

@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';

@layer base {
  body {
    @apply bg-gray-50 text-gray-900 font-sans;
  }
  h1 {
    @apply text-3xl font-bold text-primary-900;
  }
  /* その他のベーススタイル */
}

@layer components {
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2 rounded-md font-medium text-white bg-primary-600 hover:bg-primary-700 transition-colors;
  }
  .card {
    @apply bg-white rounded-card shadow-md p-6 hover:shadow-lg transition-shadow;
  }
  .tag {
    @apply inline-block px-3 py-1 mr-2 mb-2 text-xs font-medium rounded-full bg-primary-100 text-primary-800;
  }
}

4. Linterの設定

AIに実装してもらったものは特に構文チェックやテストを中心にチェックを行うべきです。 そうすることで、明らかに変な処理などを防ぐことができます。

ESLint

npm install -D eslint @nuxtjs/eslint-config-typescript eslint-plugin-vue typescript

.eslintrc.jsファイルを作成:

module.exports = {
  root: true,
  env: {
    browser: true,
    node: true,
  },
  parser: 'vue-eslint-parser',
  parserOptions: {
    parser: '@typescript-eslint/parser',
    ecmaVersion: 2020,
    sourceType: 'module',
  },
  extends: [
    '@nuxtjs/eslint-config-typescript',
    'plugin:vue/vue3-recommended',
    'plugin:nuxt/recommended',
  ],
  plugins: [],
  rules: {
    'vue/multi-word-component-names': 'off',
    'vue/no-multiple-template-root': 'off',
    // その他のルール設定
  },
}

Markuplint

npm install -D markuplint @markuplint/vue-parser

.markuplintrc.jsファイルを作成:

module.exports = {
  parser: {
    '.vue$': '@markuplint/vue-parser',
  },
  specs: {
    '.vue$': '@markuplint/vue-spec',
  },
  extends: [
    'markuplint:recommended',
  ],
  excludeFiles: [
    'node_modules/**/*',
    'dist/**/*',
  ],
  rules: {
    // ルール設定
  },
  nodeRules: [
    {
      selector: 'img',
      rules: {
        'required-attr': ['alt'],
      },
    },
  ],
}

package.jsonにスクリプト追加

"scripts": {
  "dev": "nuxt dev",
  "build": "nuxt build",
  "preview": "nuxt preview",
  "lint": "eslint --ext .js,.ts,.vue .",
  "lint:fix": "eslint --ext .js,.ts,.vue . --fix",
  "markuplint": "markuplint \"**/*.vue\"",
  "markuplint:fix": "markuplint --fix \"**/*.vue\""
}

5. ブログの基本構造

ディレクトリ構造

my-blog/
├── content/
│   ├── articles/
│   │   ├── first-post.md
│   │   └── second-post.md
├── components/
│   ├── ArticleCard.vue
│   └── content/
│       └── ArticleInternalLink.vue
├── composables/
│   ├── useArticles.ts
│   └── useArticleUtils.ts
│   └── useTagFilter.ts
│   └── useOgp.ts
├── pages/
│   ├── index.vue
│   └── articles/
│       └── [slug].vue
│       └── index.vue
├── utils/
|   └── canvasUtils.ts
|   └── ogpGenerator.ts

コンテンツファイルの例

// content/articles/first-post.md
---
title: 初めての投稿
description: Nuxt Contentを使った最初のブログ記事
date: 2025-03-01
tags: [nuxt, content, blog]
---

# 初めての投稿

これはNuxt Contentを使った最初のブログ記事です。
マークダウンで簡単に記事を書くことができます。

6. 実装例

記事カードコンポーネント

<!-- components/ArticleCard.vue -->
<template>
  <div class="card group transition-all duration-300 hover:translate-y-[-4px]">
    <NuxtLink :to="article.path" class="block">
      <div class="space-y-2">
        <div class="flex items-center text-sm text-gray-500">
          <span class="mr-2">{{ formatDate(article.date) }}</span>
          <span class="h-1 w-1 rounded-full bg-gray-400" />
          <span class="ml-2">{{ readingTime(article.body) }} 分で読めます</span>
        </div>
        <h3 class="text-xl font-bold text-primary-900 line-clamp-2 group-hover:text-primary-700">
          {{ article.title }}
        </h3>
        <p class="text-gray-600 line-clamp-3">
          {{ article.description }}
        </p>
        <div class="flex flex-wrap pt-2">
          <span
            v-for="tag in article.tags"
            :key="tag"
            class="tag"
          >
            {{ tag }}
          </span>
        </div>
      </div>
    </NuxtLink>
  </div>
</template>

<script setup lang="ts">
import type { Article } from '../types/article'

const props = defineProps<{
  article: Article
}>()

const article = props.article

// 日付フォーマット関数の呼び出し
const formatDate = useArticleUtils().formatDate

// 記事の読了時間を計算する関数の呼び出し
const readingTime = useArticleUtils().calculateReadingTime
</script>

記事一覧ページ

<!-- pages/index.vue -->
<template>
  <div class="container mx-auto px-4 py-8">
    <header class="mb-12 text-center">
      <h1 class="text-4xl font-bold text-primary-900 mb-4">
        Akkyの実験レポート
      </h1>
      <p class="text-lg text-gray-600 max-w-2xl mx-auto">
        個人で試したWeb開発技術、プログラミングTips、ベストプラクティスについての記事をお届けします。
      </p>
    </header>

    <div class="flex flex-wrap -mx-4">
      <!-- サイドバー -->
      <div class="w-full md:w-1/4 px-4 mb-8 md:mb-0">
        <div class="bg-white rounded-card shadow-md p-6">
          <h2 class="text-xl font-bold mb-4 text-primary-800">
            カテゴリー
          </h2>
          <ul class="space-y-2">
            <li v-for="tag in uniqueTags" :key="tag">
              <a
                href="#"
                class="flex items-center justify-between text-gray-700 hover:text-primary-600"
                @click.prevent="filterByTag(tag)"
              >
                <span>{{ tag }}</span>
                <span class="bg-gray-100 text-gray-600 text-xs font-medium rounded-full px-2 py-1">
                  {{ getTagCount(tag) }}
                </span>
              </a>
            </li>
          </ul>
        </div>
      </div>

      <!-- メインコンテンツ -->
      <div class="w-full md:w-3/4 px-4">
        <div v-if="activeTag" class="mb-6 flex items-center">
          <h2 class="text-xl font-semibold text-gray-700 mr-2">
            タグ: {{ activeTag }}
          </h2>
          <button
            class="text-sm text-gray-500 hover:text-primary-600"
            @click="clearTagFilter"
          >
            <span class="text-primary-600">×</span> クリア
          </button>
        </div>

        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
          <ArticleCard
            v-for="article in filteredArticles"
            :key="article.path"
            :article="article"
          />
        </div>

        <div v-if="filteredArticles.length === 0" class="text-center py-12">
          <p class="text-gray-500 text-lg">
            記事が見つかりませんでした。
          </p>
          <button
            class="mt-4 btn"
            @click="clearTagFilter"
          >
            すべての記事を表示
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts">
const { getArticles, filterArticlesByTag, extractUniqueTags } = useArticles()
const { activeTag, filterByTag, clearTagFilter } = useTagFilter()

// 記事一覧を取得
const articles = await getArticles()

// タグでフィルタリングした記事リスト
const filteredArticles = computed(() => filterArticlesByTag(articles, activeTag.value))

// すべてのユニークなタグを抽出
const uniqueTags = computed(() => extractUniqueTags(articles.value))

// タグの記事数を取得
function getTagCount(tag: string): number {
  if (!articles.value) { return 0 }
  return articles.value.filter(
    article => article.tags && article.tags.includes(tag)
  ).length
}

// SEOメタタグを設定
const { homePageMeta } = useOgp()

useHead({
  title: homePageMeta.title,
  meta: [
    { name: 'description', content: homePageMeta.description },
    { property: 'og:title', content: homePageMeta.title },
    { property: 'og:description', content: homePageMeta.description },
    { property: 'og:image', content: homePageMeta.image },
    { property: 'og:url', content: homePageMeta.url },
    { property: 'og:type', content: 'website' },
    { name: 'twitter:title', content: homePageMeta.title },
    { name: 'twitter:description', content: homePageMeta.description }
  ]
})
</script>

記事詳細ページ

<!-- pages/articles/[slug].vue -->
<template>
  <div v-if="article" class="min-h-screen bg-gray-50">
    <!-- ヒーローセクション -->
    <header class="w-full bg-primary-800 text-white">
      <div class="container mx-auto px-4 py-16 md:py-24">
        <div class="max-w-3xl mx-auto">
          <div class="flex items-center space-x-2 text-sm mb-6">
            <NuxtLink to="/" class="text-primary-200 hover:text-white">
              ホーム
            </NuxtLink>
            <span class="text-primary-400">/</span>
            <NuxtLink to="/articles" class="text-primary-200 hover:text-white">
              記事一覧
            </NuxtLink>
            <span class="text-primary-400">/</span>
            <span class="text-primary-100">{{ article.title }}</span>
          </div>

          <h1 class="text-3xl md:text-4xl lg:text-5xl font-bold mb-6 text-white">
            {{ article.title }}
          </h1>

          <div class="flex flex-wrap items-center text-sm text-primary-200 mb-6">
            <div class="flex items-center mr-6 mb-2">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
              </svg>
              <span>{{ formatDate(article.date) }}</span>
            </div>

            <div class="flex items-center mr-6 mb-2">
              <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
              </svg>
              <span>{{ readingTime(article.body) }} 分で読めます</span>
            </div>
          </div>

          <div class="flex flex-wrap">
            <span
              v-for="tag in article.tags"
              :key="tag"
              class="bg-primary-700 text-primary-100 rounded-full px-3 py-1 text-xs font-medium mr-2 mb-2"
            >
              {{ tag }}
            </span>
          </div>
        </div>
      </div>
    </header>

    <!-- メインコンテンツ -->
    <main class="container mx-auto px-4 py-12">
      <div class="max-w-3xl mx-auto">
        <!-- 記事本文 -->
        <article class="bg-white rounded-lg shadow-md p-6 md:p-10 mb-10">
          <div class="prose prose-lg max-w-none">
            <ContentRenderer :value="article" />
          </div>
        </article>

        <!-- 関連記事(オプション) -->
        <div v-if="relatedArticles && relatedArticles.length > 0" class="mb-10">
          <h2 class="text-2xl font-bold text-gray-900 mb-6">
            関連記事
          </h2>
          <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
            <div
              v-for="relatedArticle in relatedArticles"
              :key="relatedArticle.path"
              class="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
            >
              <NuxtLink :to="relatedArticle.path" class="block">
                <div class="p-4">
                  <h3 class="font-bold text-gray-900 mb-2 line-clamp-2">
                    {{ relatedArticle.title }}
                  </h3>
                  <p class="text-gray-600 text-sm">
                    {{ formatDate(relatedArticle.date) }}
                  </p>
                </div>
              </NuxtLink>
            </div>
          </div>
        </div>

        <!-- ナビゲーションリンク -->
        <div class="flex justify-between items-center">
          <NuxtLink
            to="/"
            class="inline-flex items-center text-primary-600 hover:text-primary-800"
          >
            <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
            </svg>
            記事一覧に戻る
          </NuxtLink>
        </div>
      </div>
    </main>
  </div>

  <!-- ローディング状態 -->
  <div v-else class="min-h-screen flex items-center justify-center bg-gray-50">
    <div class="text-center">
      <svg
        class="animate-spin h-10 w-10 text-primary-600 mx-auto mb-4"
        xmlns="http://www.w3.org/2000/svg"
        fill="none"
        viewBox="0 0 24 24"
      >
        <circle
          class="opacity-25"
          cx="12"
          cy="12"
          r="10"
          stroke="currentColor"
          stroke-width="4"
        />
        <path
          class="opacity-75"
          fill="currentColor"
          d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
        />
      </svg>
      <p class="text-gray-600">
        読み込み中...
      </p>
    </div>
  </div>
</template>

<script setup lang="ts">
import { useRoute } from 'vue-router'

const route = useRoute()

const { getArticles, getArticleBySlug, getRelatedArticles } = useArticles()
const { formatDate, calculateReadingTime } = useArticleUtils()

const article = await getArticleBySlug(route.path)

const allArticles = await getArticles()
const relatedArticles = computed(() => {
  if (article.value && allArticles.value) {
    return getRelatedArticles(article.value, allArticles.value, 3)
  } else {
    return []
  }
})

// 記事の読了時間を計算
const readingTime = calculateReadingTime

// 記事が読み込まれたらSEOメタタグを設定
const { generateArticleMeta } = useOgp()

watch(article, (newArticle) => {
  if (newArticle) {
    const meta = generateArticleMeta(newArticle)
    useHead({
      title: meta.title,
      meta: [
        { name: 'description', content: meta.description },
        { name: 'keywords', content: 'tags' in meta ? meta.tags.join(', ') : '' },
        { property: 'og:title', content: meta.title },
        { property: 'og:description', content: meta.description },
        { property: 'og:url', content: `https://techblog.akky-cr.xyz${route.path}` },
        { property: 'og:type', content: 'article' },
        { property: 'og:image', content: meta.image },
        { property: 'article:published_time', content: 'publishedTime' in meta ? meta.publishedTime.toString() : '' },
        { property: 'article:tag', content: 'tags' in meta ? meta.tags.join(', ') : '' },
        { name: 'twitter:title', content: meta.title },
        { name: 'twitter:description', content: meta.description },
        { name: 'twitter:image', content: meta.image }
      ],
      link: [
        { rel: 'canonical', href: `https://techblog.akky-cr.xyz${route.path}` }
      ]
    })
  }
}, { immediate: true })
</script>

7. コードのリファクタリング

コードの可読性と再利用性を高めるために、ロジックを composables に切り出します。

記事データ取得・処理(useArticles.ts)

// composables/useArticles.ts
import type { Article } from '../types/article'

export function useArticles() {
  // 全記事を取得
  const getArticles = async() => {
    const { data } = await useAsyncData<Article[]>('articles', () =>
      queryCollection('articles')
        .order('date', 'DESC')
        .all()
    )
    return data
  }

  // 特定の記事を取得
  const getArticleBySlug = async(slug: string) => {
    const { data } = await useAsyncData<Article>(`article-${slug}`, () =>
      queryCollection('articles').path(slug).first()
    )

    if (!data.value) {
      throw createError({
        statusCode: 404,
        message: '記事が見つかりません'
      })
    }

    return data
  }

  // 関連記事を取得
  const getRelatedArticles = (currentArticle: Article, allArticles: Article[], limit = 3) => {
    if (!currentArticle || !currentArticle.tags || !allArticles) { return [] }

    return allArticles
      .filter(article =>
        article.path !== currentArticle.path && // 現在の記事を除外
        article.tags && // タグがある記事のみ
        article.tags.some(tag => currentArticle.tags!.includes(tag)) // 共通のタグがある
      )
      .slice(0, limit)
  }

  // タグでフィルタリング
  const filterArticlesByTag = (articles: Ref<Article[] | null>, tag: string) => {
    if (!tag || !articles.value) { return articles.value || [] }
    return articles.value.filter(article =>
      article.tags && article.tags.includes(tag)
    )
  }

  // すべてのユニークなタグを抽出
  const extractUniqueTags = (articles: Article[] | null) => {
    if (!articles) { return [] }
    const tags = new Set<string>()

    articles.forEach(article => {
      if (article.tags && Array.isArray(article.tags)) {
        article.tags.forEach(tag => tags.add(tag))
      }
    })

    return Array.from(tags).sort()
  }

  // タグごとの記事数を取得
  const getTagCount = (articles: Article[] | null, tag: string) => {
    if (!articles) { return 0 }
    return articles.filter(
      article => article.tags && article.tags.includes(tag)
    ).length
  }

  return {
    getArticles,
    getArticleBySlug,
    getRelatedArticles,
    filterArticlesByTag,
    extractUniqueTags,
    getTagCount
  }
}

記事表示用ユーティリティ(useArticleUtils.ts)

// composables/useArticleUtils.ts
import type { MinimalNode, MinimalTree } from '@nuxt/content';

export function useArticleUtils() {
  // 日付フォーマット関数
  const formatDate = (date: Date): string => {
    return new Date(date).toLocaleDateString('ja-JP')
  }

  // 記事の読了時間を計算
  const calculateReadingTime = (content: any | MinimalTree): number => {
    if(content.type !== 'minimal') {return 1}

    const extractText = (content: MinimalTree) => content.value.map((node: MinimalNode) => node[2]).join('')

    const bodyText = extractText(content)
        
    const wordsPerMinute = 500
    const words = bodyText.trim().split(/\s+/).length

    return Math.max(1, Math.ceil(words / wordsPerMinute))
  }

  return {
    formatDate,
    calculateReadingTime
  }
}

タグフィルタリング(useTagFilter.ts)

// composables/useTagFilter.ts
export function useTagFilter() {
  const activeTag = ref('')

  // タグでフィルタリング
  const filterByTag = (tag: string): void => {
    activeTag.value = tag
  }

  // フィルターをクリア
  const clearTagFilter = (): void => {
    activeTag.value = ''
  }

  return {
    activeTag,
    filterByTag,
    clearTagFilter
  }
}

8. デプロイ

静的サイトとしてビルドします:

npm run generate

ビルドが完了したら、.output/publicディレクトリをホスティングサービスにアップロードして公開します。

まとめ

以上で、nuxt-content v3を使ってTailwindCSSを導入したブログ構築のガイドは終了です。 今回紹介したパターンをベースに、自身のブログやコンテンツサイトをカスタマイズして、独自のウェブサイトを作成してみてください。