Nuxt.jsでリンクカードを簡単に実装する方法(改訂版)

Nuxt.jsでリンクカードを簡単に実装する方法(改訂版)

Nuxt.jsを利用して、ブログ記事にリンクカードを埋め込む完全ガイド。open-graph-scraperの導入からLinkCardコンポーネントの作成、nuxt.config.tsとpackage.jsonの設定まで、すべての手順をカバーします。

はじめに

これまでの記事では、Nuxt.jsでTwitterやYouTubeの埋め込み方法を解説しました。今回はそれに加え、多くのブログで必要とされる「リンクカード」の実装方法を掘り下げます。

この記事では、Nuxt.js@nuxt/content を利用して、ブログ記事にリッチなリンクカードを埋め込む手順を網羅的に解説します。リンクカードは、外部リンクを視覚的に分かりやすく読者に提示するための重要なUIコンポーネントです。open-graph-scraper の導入からコンポーネント作成、各種設定ファイルの更新まで、ステップバイステップで説明します。

修正版の概要

Nuxt.js@nuxt/content のバージョンアップに伴い、以前の方法ではビルドや静的サイト生成(generate)時にリンクカードが正しく機能しない問題が確認されました。本記事(改訂版)では、この問題に対応するための最新の実装方法を詳しく解説します。

リンクカード参考サイト

上記はそれぞれZennとAstroでリンクカードを実装する方法を解説した記事です。特に画像取得のロジックは、Astro向けの記事を参考にさせていただきました。

これらの記事ではNuxt 3および@nuxt/content v3環境での具体的な解説がなかったため、本記事でその方法を詳しく説明します。

Nuxtでリンクカードを使う方法

Nuxtにリンクカードを埋め込む

Nuxt.jsと@nuxt/contentを使用してブログサイトに美しいリンクカードを埋め込む方法を紹介します。

上記の参考サイトリンクカードは以下の方法で表示させています

1. open-graph-scraperのインストール

はじめに、リンク先のOGP情報を取得するためのパッケージ open-graph-scraper をインストールします。これにより、指定したURLからタイトル、説明、画像といったメタデータを効率的に取得できます。

pnpm add open-graph-scraper

また、Nuxt.jsのビルドツールとしてViteを利用するため、@vitejs/plugin-vue がインストールされていない場合は以下のコマンドを実行してインストールしてください。

pnpm add @vitejs/plugin-vue

2. LinkCard.vueコンポーネントの作成

次に、app/components/content/LinkCard.vue ファイルを作成します。このコンポーネントは、指定されたURLからOGPデータを取得し、それを基にリンクカードを表示します。

旧バージョン(UnoCSS使用)

LinkCard.vue (UnoCSS版)
<script setup lang="ts">
import { onMounted, ref } from "vue";
const props = defineProps({
  propsUrl: String,
  title: String,
  siteUrl: String,
  description: String,
});

// 環境変数を取得
const isDevRun = import.meta.env.DEV; // nuxt dev の場合に true

// リアクティブなデータとしてogpDataを定義
const ogpData = ref(null);

if (isDevRun) {
  // dev時
  onMounted(async () => {
    try {
      const data = await $fetch(`/api/ogp?url=${encodeURIComponent(props.propsUrl)}`);
      console.log("APIレスポンス:", data);
      ogpData.value = data;
    } catch (error) {
      console.error("Fetch error:", error);
    }
  });
} else {
  // generate時
  const { data, error } = await useFetch(
    `/api/ogp?url=${encodeURIComponent(props.propsUrl)}`
  );
  if (error.value) {
    console.error("Fetch error:", error.value);
  } else {
    ogpData.value = data.value;
  }
}

// 取得文字列の調整-タイトルの文字数を20文字で切り捨てる
const maxLength = 20

const limitedTitle = computed(() => {
  const base = ogpData.value.ogTitle || props.title || ''
  return base.length > maxLength ? base.substr(0, maxLength) + '...' : base
})
</script>

<template>
  <div class="link-card" v-if="ogpData">
    <a :href="propsUrl" target="_blank" rel="noopener">
      <div class="link-card-content">
        <div self-stretch>
          <img :src="ogpData.ogImage?.[0]?.url || '/img/ogp.png'" alt="OG Image"  tb:mt-10 />
        </div>
        <div class="at-sm:flex-grow-2 tb:pl-2">
          <h3 class="m-0 p-0 text-24px sm:(text-h5_sm leading-h5) tb:(text-h4_sm leading-h3) lg:(text-1.5rem leading-lg)">{{ limitedTitle }}</h3>
          <!-- p class="mt-1 mb-2 p-0 underline text-sm">{{ ogpData.ogUrl || props.siteUrl }}</p -->
          <p class="m-0 p-0 txt-limit">{{ ogpData.ogDescription || props.description }}</p>
        </div>
      </div>
    </a>
  </div>
  <div v-else>
    <p>Link Card Loading...</p>
  </div>
</template>
<style scoped lang="scss">
.link-card {
  border: 1px solid #ddd;
  padding: 16px;
  border-radius: 8px;
  transition: box-shadow 0.3s;
  a {
    text-decoration: none;
    &::after {
      content: none;
    }
  }
}
.link-card img {
  display: block;
  max-width: 100%;
  height: auto;
}
.link-card:hover {
  box-shadow: 3px 4px 8px rgb(0 0 0 /0.6);
}
.link-card-content {
  display: grid;
  @screen sm {
    grid-template-rows: 50% 50%;
    gap: 0;
  }
  @screen tb {
    grid-template-columns: 40% 60%;
    gap: 10px;
  }
  .txt-limit {
    overflow: hidden;
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2; /* 任意の行数を指定 */
  }
}
</style>

このコンポーネントは、UnoCSSを使用してスタイリングされています。

新バージョン(Docus統合・Tailwind CSS使用)

LinkCard.vue (Docus統合版)
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'

const props = defineProps({
  propsUrl: String,
  title: String,
  siteUrl: String,
  description: String,
})

const isDevRun = import.meta.env.DEV
const ogpData = ref(null)

if (isDevRun) {
  onMounted(async () => {
    try {
      const data = await $fetch(
        `/api/ogp?url=${encodeURIComponent(props.propsUrl)}`,
      )
      ogpData.value = data
    }
    catch (error) {
      console.error('Fetch error:', error)
    }
  })
}
else {
  const { data, error } = await useFetch(
    `/api/ogp?url=${encodeURIComponent(props.propsUrl)}`,
  )

  if (error.value) {
    console.error('Fetch error:', error.value)
  }
  else {
    ogpData.value = data.value
  }
}

const maxLength = 40

const limitedTitle = computed(() => {
  if (!ogpData.value) return ''
  const base = ogpData.value.ogTitle || props.title || ''
  return base.length > maxLength ? `${base.substring(0, maxLength)}...` : base
})

const limitedDescription = computed(() => {
  if (!ogpData.value) return ''
  const base = ogpData.value.ogDescription || props.description || ''
  const maxDescLength = 120
  return base.length > maxDescLength ? `${base.substring(0, maxDescLength)}...` : base
})
</script>

<template>
  <UCard
    v-if="ogpData"
    :ui="{
      root: 'overflow-hidden rounded-md ring-1 ring-gray-200 dark:ring-gray-800 hover:ring-gray-300 dark:hover:ring-gray-700 transition-colors duration-200 bg-white dark:bg-gray-900',
      body: 'p-0 sm:p-0',
      header: 'p-0 sm:p-0',
    }"
    class="my-3 not-prose"
  >
    <NuxtLink
      :to="propsUrl"
      target="_blank"
      rel="noopener noreferrer"
      class="block text-inherit m-0 p-0 link-card-link"
    >
      <div class="link-card-content">
        <div class="link-card-image">
          <img
            :src="ogpData.ogImage?.[0]?.url || '/img/ogp.png'"
            :alt="limitedTitle || 'リンク先のサムネイル画像'"
          >
        </div>
        <div class="link-card-text">
          <h3 class="link-card-title">
            {{ limitedTitle }}
          </h3>
          <p class="link-card-description">
            {{ limitedDescription }}
          </p>
          <p class="link-card-url">
            {{ ogpData.ogUrl || props.siteUrl || propsUrl }}
          </p>
        </div>
      </div>
    </NuxtLink>
  </UCard>
  <div v-else class="flex items-center gap-2 p-4 text-gray-500/70 dark:text-slate-400/70 text-sm">
    <UIcon name="i-heroicons-arrow-path" class="animate-spin" />
    <span>Loading link card...</span>
  </div>
</template>

<style scoped>
@reference "tailwindcss";

.link-card-content {
  @apply grid grid-cols-1 gap-0 items-stretch;
}

@media (min-width: 768px) {
  .link-card-content {
    @apply grid-cols-[200px_1fr] h-[113px];
  }
}

.link-card-image {
  @apply relative w-full aspect-video overflow-hidden bg-gray-200/30 dark:bg-slate-700/70 block leading-none;
}

@media (min-width: 768px) {
  .link-card-image {
    @apply w-[200px] h-[113px] aspect-auto flex-shrink-0;
  }
}

.link-card-image img {
  @apply w-full h-full object-cover object-center block align-top m-0 p-0;
}

.link-card-text {
  @apply flex flex-col justify-start;
  padding: 0 0.75rem 0.5rem 0.75rem;
  gap: 0.25rem;
}

.link-card-title {
  font-size: 1rem !important;
  font-weight: 600;
  color: #0f172a;
  margin: 0 !important;
  line-height: 1.3 !important;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.link-card-description {
  @apply text-xs text-gray-600/90 dark:text-gray-400/90 m-0 leading-normal line-clamp-2;
}

.link-card-url {
  font-size: 0.6875rem;
  color: rgba(107, 114, 128, 0.8);
  margin: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.dark .link-card-title {
  color: #e2e8f0;
}

.dark .link-card-url {
  color: rgba(148, 163, 184, 0.8);
}

/* リンクの下線を削除 */
.link-card-link {
  text-decoration: none !important;
}

.link-card-link:hover {
  text-decoration: none !important;
}

/* 外部リンクアイコンを非表示 */
.link-card-link :deep(.icon-external),
.link-card-link :deep([class*="external"]),
.link-card-link::after,
.link-card-link :deep(svg),
.link-card-link :deep(.icon),
.link-card-link :deep(a[target="_blank"]::after),
.link-card-link :deep(a[rel*="noopener"]::after) {
  display: none !important;
}
</style>

このコンポーネントは、渡されたURLのOGP(Open Graph Protocol)データを非同期で取得し、リンクカードとして描画します。開発サーバー実行時(nuxi dev)と静的サイト生成時(nuxi generate)でデータの取得方法を切り替えるように設計されています。

主な変更点:

  • UCardコンポーネントを使用してカードUIを実装
  • Tailwind CSS@applyディレクティブでスタイルを記述
  • not-proseクラスで.proseスタイルの影響を除外
  • レスポンシブデザインをメディアクエリとTailwindで実装
  • ダークモード対応を追加

3. server/api/ogp.ts の設置

次に、OGPデータを取得するためのサーバーAPIエンドポイントを作成します。このファイルは以前の記事では触れていませんでしたが、リンクカードを動作させるために不可欠な要素です。

ogp.ts
// server/api/ogp.ts
import { defineEventHandler, getQuery, sendError, H3Error } from 'h3'
import ogs from 'open-graph-scraper'

// タイムアウト値を設定 (例: 20秒 = 20000ミリ秒)
// この値は必要に応じて調整してください。
const OGS_REQUEST_TIMEOUT = 20000;

export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const url = query.url as string

  if (!url) {
    // エラーレスポンスを H3Error を使って返すことで、
    // Nuxt (Nitro) が適切なHTTPステータスコードを設定しやすくなります。
    const error = new H3Error('URL is required');
    error.statusCode = 400; // Bad Request
    return sendError(event, error);
  }

  try {
    console.log(`[OGP API] Fetching OGP for: ${url} with timeout: ${OGS_REQUEST_TIMEOUT}ms`);
    const { result, error: ogsInternalError } = await ogs({
      url,
      timeout: OGS_REQUEST_TIMEOUT, // タイムアウト値をミリ秒で指定
      // open-graph-scraper はリダイレクトに追従する 'followRedirect': true がデフォルトです。
      // 他にも 'retry': 2 (デフォルト)などのオプションがあります。
    });

    // ogs はエラーがあっても例外を投げず、結果オブジェクト内の error フラグや
    // success: false で示すことがあります。
    if (ogsInternalError || !result || !result.success) {
      const errorMessage = result?.ogError || 'Failed to scrape OGP data due to an unknown reason from ogs.';
      console.error(`[OGP API] OGS failed for ${url}:`, errorMessage, result);
      const error = new H3Error(errorMessage);
      error.statusCode = 502; // Bad Gateway (外部サービスからのエラーとして)
      // クライアント側で詳細なエラー情報が必要な場合は、result を data に含めることもできます
      // error.data = { ogsResult: result };
      return sendError(event, error);
    }

    // console.log('[OGP API] OGP情報:', result);
    return result; // 成功時は ogs の result オブジェクトを返す

  } catch (error: any) { // try-catch は予期せぬ例外(ライブラリのバグなど)を捕捉
    console.error(`[OGP API] Unhandled exception for ${url}:`, error);
    let errorMessage = 'An unexpected error occurred while fetching OGP data.';
    let statusCode = 500; // Internal Server Error

    // タイムアウト関連のエラーかどうかを判定
    // (error.name や error.message はエラーの種類によって変わるため、複数のキーワードでチェック)
    if (error.name === 'AbortError' || // Node.js の AbortController や undici の fetch でタイムアウト時に発生
        (error.message && error.message.toLowerCase().includes('timeout')) ||
        (error.code && error.code === 'ETIMEDOUT')) { // Node.js の net モジュールなどで発生するタイムアウト
      errorMessage = 'The OGP request timed out while fetching from the external site.';
      statusCode = 504; // Gateway Timeout
    }

    const h3Error = new H3Error(errorMessage);
    h3Error.statusCode = statusCode;
    // error.cause や error.stack など、デバッグに役立つ情報をdataに含めることも検討
    // h3Error.data = { originalError: error.toString(), stack: error.stack };
    return sendError(event, h3Error);
  }
})

処理する記事の数が多い場合や、取得先のサイトの応答が遅い場合に、OGPデータの取得に失敗することがあります。これを防ぐため、コード内でタイムアウト値を設定し、安定したデータ取得を目指します。

4. nuxt.config.tsの設定

nuxt.config.ts ファイルに以下の設定を追加します。 以前の記事ではvite.pluginsに設定を記述していましたが、現在の構成ではnitro.rollupConfig内に設定を移すことが推奨されます。

nuxt.config.ts
import vue from '@vitejs/plugin-vue'

export default defineNuxtConfig({
  // ... 他の設定

  nitro: {
    rollupConfig: {
      plugins: [Vue({
        template: {
          customElement: true,
          },
      }),

さらに、experimental オプションの追加も重要です。この設定を有効にすることで、静的サイト生成(SSG)時に各ページのAPIレスポンス(この場合はOGPデータ)が _payload.json という静的ファイルとして書き出され、クライアントサイドでの再取得が不要になります。

nuxt.config.ts
  experimental: {
    payloadExtraction: true,
    renderJsonPayloads: true,
}

以上の手順で、Nuxt.jsプロジェクトにリンクカードを埋め込む準備が整いました。LinkCard コンポーネントを使用することで、美しいリンクカードをブログ記事に簡単に追加できます。

使用例:

  • mdファイルに埋め込む場合
Inline Components
::link-card{propsUrl="https://exanple.com"}
::

或いは、Block Components形式で、
::link-card
---
propsUrl: "https://exanple.com"
---
::
  • vueファイルに埋め込む場合
<template>
  <LinkCard
    propsUrl="https://example.com"
  />
</template>

このコンポーネントを使用することで、指定したURLのOGPデータを自動的に取得し、スタイリングされたリンクカードとして表示します。

5.環境変数の設定

最後に、環境変数の設定を見直します。以前は .env ファイルでVITE_APP_ENVを設定する方法を案内していましたが、現在のNuxtのバージョンではビルドプロセスで変数が正しく渡されないケースがあります。より確実な方法として、package.jsonのスクリプト内で直接変数を指定することを推奨します。

package.json
    "build": "VITE_APP_ENV=production nuxi build",
    "generate": "VITE_APP_ENV=production nuxi generate",

まとめ

本記事では、Nuxt.jsと@nuxt/content環境で、OGP情報を活用したリッチなリンクカードを実装する一連の手順を解説しました。サーバーAPIの設置、コンポーネントの作成、そしてビルド設定の最適化により、開発時も静的生成時も安定して動作するリンクカードが実現できます。