はじめに
これまでの記事では、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
yarn add open-graph-scraper
npm install open-graph-scraper
また、Nuxt.jsのビルドツールとしてViteを利用するため、@vitejs/plugin-vue がインストールされていない場合は以下のコマンドを実行してインストールしてください。
pnpm add @vitejs/plugin-vue
yarn add @vitejs/plugin-vue
npm install @vitejs/plugin-vue
2. LinkCard.vueコンポーネントの作成
次に、app/components/content/LinkCard.vue ファイルを作成します。このコンポーネントは、指定されたURLからOGPデータを取得し、それを基にリンクカードを表示します。
旧バージョン(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使用)
<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エンドポイントを作成します。このファイルは以前の記事では触れていませんでしたが、リンクカードを動作させるために不可欠な要素です。
// 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内に設定を移すことが推奨されます。
import vue from '@vitejs/plugin-vue'
export default defineNuxtConfig({
// ... 他の設定
nitro: {
rollupConfig: {
plugins: [Vue({
template: {
customElement: true,
},
}),
さらに、experimental オプションの追加も重要です。この設定を有効にすることで、静的サイト生成(SSG)時に各ページのAPIレスポンス(この場合はOGPデータ)が _payload.json という静的ファイルとして書き出され、クライアントサイドでの再取得が不要になります。
experimental: {
payloadExtraction: true,
renderJsonPayloads: true,
}
以上の手順で、Nuxt.jsプロジェクトにリンクカードを埋め込む準備が整いました。LinkCard コンポーネントを使用することで、美しいリンクカードをブログ記事に簡単に追加できます。
使用例:
- mdファイルに埋め込む場合
::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のスクリプト内で直接変数を指定することを推奨します。
"build": "VITE_APP_ENV=production nuxi build",
"generate": "VITE_APP_ENV=production nuxi generate",
まとめ
本記事では、Nuxt.jsと@nuxt/content環境で、OGP情報を活用したリッチなリンクカードを実装する一連の手順を解説しました。サーバーAPIの設置、コンポーネントの作成、そしてビルド設定の最適化により、開発時も静的生成時も安定して動作するリンクカードが実現できます。


