1. 結論(この記事で得られること)
Nuxt3のSEO対策、正直「公式ドキュメント読めば分かるでしょ」って思ってた時期が私にもありました。でも実務で運用し始めると、「useHead」と「useSeoMeta」どっち使えばいいの?動的ルートのOGP画像どう管理する?SSRとCSRで挙動違うけど大丈夫?って問題が次々出てくるんですよね。
この記事では以下が手に入ります:
- useHead / useSeoMeta / app.vue の使い分け基準(迷わない判断軸)
- 動的meta・OGP設定の実装パターン(API連携含む)
- SSR/SSG環境でのmeta反映を担保する実装とテスト手法
- AI活用によるmeta設計レビューとクローラー検証の自動化
- 本番障害を起こさないためのチェックリストと監視設計
コード例はそのままプロジェクトに持ち込めるレベルで書きます。レビューで「これ危ないです」と指摘される前に、安全な設計を身につけましょう。
2. 前提(環境・読者層)
想定環境
- Nuxt 3.8以降(Nuxt 3.0〜3.7でも大半は動作します)
- Node.js 18以上
- SSR / SSG どちらかを採用(CSRオンリーは対象外)
読者層
- Nuxt3でサービス開発中、SEOが要件に入っている方
- useHeadは使ってるけど「これで合ってる?」と不安な方
- 動的OGP画像やstructured dataまで実装したい中級者
前提知識
- Nuxt3の基本(pages/components/composables の役割)
- SSRとCSRの違いを理解している
- Vue 3のComposition APIが読める
私も最初は「Nuxt2の「head()」と何が違うの?」状態だったので、つまずきポイントは丁寧に解説します。
3. Before:よくあるつまずきポイント
3-1. useHead と useSeoMeta どっち?問題
// ❌ こんなコードをよく見かけます
<script setup>
useHead({
title: '記事タイトル',
meta: [
{ name: 'description', content: '説明文' },
{ property: 'og:title', content: '記事タイトル' },
{ property: 'og:description', content: '説明文' }
]
})
</script>
これ自体は動くんですが、SEO特化のmetaタグには「useSeoMeta」の方が型安全で保守性が高いです。私も最初全部「useHead」で書いてて、レビューで「ここ分けましょう」と言われました。
3-2. 動的ページでmetaが反映されない
// ❌ ありがちな失敗パターン
// pages/articles/[id].vue
<script setup>
const route = useRoute()
const { data: article } = await useFetch(`/api/articles/${route.params.id}`)
useSeoMeta({
title: article.value.title // 初回レンダリング時にundefinedになる
})
</script>
SSR時の非同期データ解決タイミングを理解してないと、クローラーには空のmetaが返ります。これが原因でOGP画像が出ない障害、過去に2回やらかしました。
3-3. 全ページ共通metaの二重管理
app.vueとnuxt.config.tsとlayouts/default.vueで同じOGP画像を3箇所に書いてる…みたいなプロジェクト、意外とあります。更新時の漏れの温床です。
3-4. metaタグの優先順位を知らない
Nuxt3はmeta管理を「@unhead/vue」でやってるんですが、複数箇所で同じmetaを定義すると後勝ちです。でもこのルールを知らないと「app.vueで設定したはずなのに反映されない!」ってハマります。
4. After:基本的な解決パターン
4-1. useHead / useSeoMeta の明確な使い分け
判断基準(シンプル版)
SEO関連meta(title, description, OGP, Twitter Card)
使うAPI: 「useSeoMeta」
理由: 型安全・自動補完・重複排除
link, script, style, htmlAttrs
使うAPI: 「useHead」
理由: useSeoMetaでは扱えない
structured data(JSON-LD)
使うAPI: 「useHead」 + script
理由: scriptタグが必要
実装例
// ✅ pages/articles/[id].vue
<script setup lang="ts">
const route = useRoute()
const { data: article } = await useFetch(`/api/articles/${route.params.id}`)
// SEO特化のmetaはuseSeoMeta
useSeoMeta({
title: article.value?.title,
description: article.value?.excerpt,
ogTitle: article.value?.title,
ogDescription: article.value?.excerpt,
ogImage: article.value?.ogImage,
ogUrl: `https://example.com/articles/${route.params.id}`,
twitterCard: 'summary_large_image',
})
// script/linkなどはuseHead
useHead({
link: [
{ rel: 'canonical', href: `https://example.com/articles/${route.params.id}` }
],
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.value?.title,
datePublished: article.value?.publishedAt,
})
}
]
})
</script>
ポイント:「article.value?.title」のOptional Chainingは必須です。SSR初回レンダリング時にdataがnullの可能性があるため。
4-2. 全体デフォルト設定の一元管理
// ✅ app.vue
<script setup lang="ts">
const config = useRuntimeConfig()
// 全ページ共通のデフォルト設定
useSeoMeta({
titleTemplate: '%s | MyService', // 個別ページのtitleに自動付与
ogSiteName: 'MyService',
ogLocale: 'ja_JP',
twitterSite: '@myservice',
})
useHead({
htmlAttrs: { lang: 'ja' },
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
これで各ページは差分だけ書けばOK。titleTemplateは地味に便利で、全ページに「| サイト名」を自動付与してくれます。
4-3. 動的データのSSR対応パターン
重要な原則:metaの設定はawait後に行う
// ✅ 正しいパターン
<script setup lang="ts">
const route = useRoute()
// awaitでデータ取得完了を待つ(SSR時にブロックされる)
const { data: article } = await useFetch(`/api/articles/${route.params.id}`)
// データ取得後にmeta設定(この時点でarticle.valueは確実に存在)
if (article.value) {
useSeoMeta({
title: article.value.title,
ogImage: article.value.ogImage ?? 'https://example.com/default-ogp.jpg',
})
}
</script>
SSRの仕組み:awaitで待機→HTML生成→クローラーに返す、この流れを理解してれば怖くないです。
4-4. 型安全なmeta管理用composable
// ✅ composables/useSeoArticle.ts
export const useSeoArticle = (article: {
title: string
excerpt: string
ogImage?: string
publishedAt: string
}) => {
const route = useRoute()
const url = `https://example.com${route.path}`
useSeoMeta({
title: article.title,
description: article.excerpt,
ogTitle: article.title,
ogDescription: article.excerpt,
ogImage: article.ogImage ?? 'https://example.com/default-ogp.jpg',
ogUrl: url,
ogType: 'article',
twitterCard: 'summary_large_image',
})
useHead({
link: [{ rel: 'canonical', href: url }],
script: [
{
type: 'application/ld+json',
children: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
datePublished: article.publishedAt,
url,
})
}
]
})
}
使う側はシンプルに
// pages/articles/[id].vue
const { data: article } = await useFetch(`/api/articles/${route.params.id}`)
if (article.value) {
useSeoArticle(article.value)
}
composable化するとテストしやすい・再利用できる・型チェックが効くの三拍子です。