• #SEO
  • #Nuxt3
  • #構造化データ
  • #sitemap
  • #OGP
  • #マルチテナント
開発tax-lpメモ

Nuxt3マルチテナントLPのSEO実装

業種別LPプロジェクト(複数業種のサブドメイン運用)に対して、SEOの基盤を全ページに実装した。canonical URL、JSON-LD構造化データ、動的sitemap.xml/robots.txt、OG画像プレースホルダーまで、一気に10ステップで仕上げた作業の記録。

背景と目的

このプロジェクトは士業事務所のLPを、業種別にサブドメインで展開する構成になっている。

  • industry-a.example.com -- 業種A
  • industry-b.example.com -- 業種B
  • industry-c.example.com -- 業種C
  • industry-d.example.com -- 業種D
  • industry-e.example.com -- 業種E

各サブドメインに同じNuxt3アプリをデプロイし、サブドメインミドルウェアで業種を判定してコンテンツを切り替える仕組み。この仕組みの上にSEOを載せるため、単純に useHead() をひとつ書けば終わりという話ではなく、業種ごとに異なるcanonical URL、構造化データ、sitemap、OG画像が必要になる。

実装計画(10ステップ)

全体を以下の10ステップに分けて進めた。

  1. SEO定数・事務所情報の定義
  2. canonical URL composable
  3. 構造化データユーティリティ
  4. レイアウト修正(グローバルmetaのみに変更)
  5. トップページSEO
  6. 記事一覧ページSEO
  7. 記事詳細ページSEO
  8. 動的sitemap.xml
  9. 動的robots.txt
  10. OG画像プレースホルダー生成

Step 1: SEO定数・事務所情報の一元管理

まず seo.ts に事務所情報とSEO関連の定数をまとめた。後から名前や住所を変更する際に、ここだけ直せば全体に反映される設計。

// composables/seo.ts(概要)
export const BUSINESS_INFO = {
  name: '代表者名',
  officeName: '事務所名',
  postalCode: '000-0000',
  address: '都道府県 市区町村 番地 ビル名 号室',
  prefecture: '都道府県',
  locality: '市区町村',
  streetAddress: '番地 ビル名 号室',
  domain: 'example.com',
  email: '[email protected]',
} as const

export const SEO_DEFAULTS = {
  locale: 'ja_JP',
  twitterCard: 'summary_large_image' as const,
  ogType: 'website' as const,
} as const

ポイントは as const でリテラル型にしていること。誤字や打ち間違いをTypeScriptが検知してくれる。

この事務所情報は当初ハードコードで各コンポーネントに散在していたが、作業の途中で useBusinessInfo() composableに集約するリファクタも行った。これにより、名前の修正が発生したとき(実際に発生した)に seo.ts 1ファイルの変更で全箇所に反映できるようになった。

Step 2: canonical URL composable

サブドメイン構成では、canonical URLの生成が少し面倒になる。業種ごとに異なるサブドメインを動的に組み立てる必要がある。

// composables/useCanonicalUrl.ts(概要)
export const useCanonicalUrl = (path?: string) => {
  const industry = useCurrentIndustry()
  const route = useRoute()

  const subdomain = industry.value?.id ?? 'www'
  const domain = BUSINESS_INFO.domain // 'example.com'
  const currentPath = path ?? route.path

  return computed(() =>
    `https://${subdomain}.${domain}${currentPath}`
  )
}

useCurrentIndustry() はサブドメインミドルウェアで判定された業種情報を返すcomposable。これと組み合わせることで、どのページでも正しいcanonical URLが自動生成される。

使う側はこうなる。

const canonicalUrl = useCanonicalUrl()

useHead({
  link: [
    { rel: 'canonical', href: canonicalUrl.value }
  ]
})

Step 3: 構造化データユーティリティ

Google検索のリッチリザルトに対応するため、JSON-LD形式の構造化データを生成するユーティリティを作った。

// utils/structured-data.ts(概要)

// 事務所の基本情報(LocalBusiness)
export const createLocalBusinessJsonLd = (industry: Industry) => ({
  '@context': 'https://schema.org',
  '@type': 'AccountingService',
  name: BUSINESS_INFO.officeName,
  description: industry.description,
  url: `https://${industry.id}.${BUSINESS_INFO.domain}`, // 例: https://industry-a.example.com
  address: {
    '@type': 'PostalAddress',
    postalCode: BUSINESS_INFO.postalCode, // '000-0000'
    addressRegion: BUSINESS_INFO.prefecture, // '都道府県'
    addressLocality: BUSINESS_INFO.locality, // '市区町村'
    streetAddress: BUSINESS_INFO.streetAddress, // '番地 ビル名 号室'
    addressCountry: 'JP',
  },
  areaServed: {
    '@type': 'Country',
    name: 'JP',
  },
})

// 記事ページ用(Article)
export const createArticleJsonLd = (options: {
  title: string
  description: string
  url: string
  publishedAt: string
  updatedAt?: string
  image?: string
}) => ({
  '@context': 'https://schema.org',
  '@type': 'Article',
  headline: options.title,
  description: options.description,
  url: options.url,
  datePublished: options.publishedAt,
  dateModified: options.updatedAt ?? options.publishedAt,
  author: {
    '@type': 'Person',
    name: BUSINESS_INFO.name,
  },
  publisher: {
    '@type': 'Organization',
    name: BUSINESS_INFO.officeName,
  },
  ...(options.image ? { image: options.image } : {}),
})

@typeAccountingService にしたのは、Googleの構造化データテストツールで士業事務所として認識させるため。ProfessionalService でもよいが、より具体的な型を選んだ。

住所は PostalAddress で構造化している。addressRegion(都道府県)、addressLocality(市区町村)、streetAddress(番地以降)を分離することで、Googleがローカル検索で正しくパースできる。

Step 4: レイアウトのグローバルmeta整理

既存のレイアウトファイルにはページ固有のmetaが混在していた。これをグローバルに共通するもの(charset、viewport、og:locale、twitter:card)だけに絞り、ページ固有のmetaは各ページコンポーネントで設定する方針に整理した。

// layouts/default.vue の useHead(概要)
useHead({
  htmlAttrs: { lang: 'ja' },
  meta: [
    { property: 'og:locale', content: SEO_DEFAULTS.locale },
    { property: 'og:site_name', content: officeName },
    { name: 'twitter:card', content: SEO_DEFAULTS.twitterCard },
  ],
})

og:site_name には業種に応じた事務所名を入れている。サブドメインが業種Aなら「事務所名 | 業種Aに特化した税務支援」のようなサフィックスが付く。

Step 5-7: 各ページのSEO実装

トップページ

トップページではJSON-LD(LocalBusiness)を埋め込み、業種に応じたtitle/descriptionを設定する。

// pages/index.vue(概要)
const industry = useCurrentIndustry()
const canonicalUrl = useCanonicalUrl('/')
const jsonLd = createLocalBusinessJsonLd(industry.value)

useHead({
  title: `${industry.value.label}に特化 | ${BUSINESS_INFO.officeName}`, // 例: '業種Aに特化 | 事務所名'
  meta: [
    { name: 'description', content: industry.value.description },
    { property: 'og:title', content: `${industry.value.label}に特化` },
    { property: 'og:description', content: industry.value.description },
    { property: 'og:url', content: canonicalUrl.value },
    { property: 'og:image', content: `https://${industry.value.id}.${BUSINESS_INFO.domain}/og/${industry.value.id}.svg` }, // 例: https://industry-a.example.com/og/industry-a.svg
  ],
  link: [
    { rel: 'canonical', href: canonicalUrl.value },
  ],
  script: [
    { type: 'application/ld+json', children: JSON.stringify(jsonLd) },
  ],
})

記事一覧ページ

記事一覧では CollectionPage 型の構造化データを出力する。一覧ページ自体が検索エンジンにインデックスされることを想定し、業種ごとの記事一覧として意味のあるtitleを生成する。

記事詳細ページ

記事詳細では Article 型のJSON-LDを生成する。publishedAtupdatedAt をfrontmatterから取得し、検索結果に「公開日」や「最終更新日」が表示されるようにした。

// pages/articles/[slug].vue(概要)
const { data: article } = await useAsyncData(...)
const canonicalUrl = useCanonicalUrl(`/articles/${article.value.slug}`)
const jsonLd = createArticleJsonLd({
  title: article.value.title,
  description: article.value.description,
  url: canonicalUrl.value,
  publishedAt: article.value.publishedAt,
  updatedAt: article.value.updatedAt,
  image: article.value.meta?.ogImage,
})

useHead({
  title: article.value.title,
  meta: [
    { name: 'description', content: article.value.description },
    { property: 'og:type', content: 'article' },
    // ...
  ],
  script: [
    { type: 'application/ld+json', children: JSON.stringify(jsonLd) },
  ],
})

Step 8: 動的sitemap.xml

Nuxt3のサーバールートとして server/routes/sitemap.xml.ts を作成し、動的にXMLを生成する。

// server/routes/sitemap.xml.ts(概要)
import { queryCollection } from '@nuxt/content/server'

export default defineEventHandler(async (event) => {
  const host = getRequestHost(event)
  const subdomain = host.split('.')[0]
  const baseUrl = `https://${host}`

  // 静的ページ
  const staticPages = [
    { url: '/', priority: '1.0', changefreq: 'weekly' },
    { url: '/articles', priority: '0.8', changefreq: 'weekly' },
  ]

  // コンテンツから記事を取得
  const articles = await queryCollection(event, 'pages')
    .where('path', 'LIKE', `/${subdomain}/%`)
    .all()

  const articleEntries = articles.map((article) => ({
    url: `/articles/${article.slug}`,
    priority: '0.6',
    changefreq: 'monthly',
    lastmod: article.updatedAt ?? article.publishedAt,
  }))

  const entries = [...staticPages, ...articleEntries]

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${entries.map((entry) => `  <url>
    <loc>${baseUrl}${entry.url}</loc>
    <priority>${entry.priority}</priority>
    <changefreq>${entry.changefreq}</changefreq>
    ${entry.lastmod ? `<lastmod>${entry.lastmod}</lastmod>` : ''}
  </url>`).join('\n')}
</urlset>`

  setResponseHeader(event, 'content-type', 'application/xml')
  return xml
})

最初は queryCollectionWithEvent を使おうとしてビルドエラーになった。Nuxt Content v3のサーバーサイドAPIは @nuxt/content/server から queryCollection(event, collectionName) をインポートする形に変わっている。ドキュメントが古い情報を参照していたため、実際のパッケージのexportsを確認して修正した。

Step 9: 動的robots.txt

server/routes/robots.txt.ts でサブドメインに応じたrobots.txtを動的に生成する。

// server/routes/robots.txt.ts(概要)
export default defineEventHandler((event) => {
  const host = getRequestHost(event)
  const baseUrl = `https://${host}`

  const robotsTxt = `User-agent: *
Allow: /

Sitemap: ${baseUrl}/sitemap.xml
`

  setResponseHeader(event, 'content-type', 'text/plain')
  return robotsTxt
})

これにより、既存の public/robots.txt は削除し、サーバールートで動的に返す形に統一した。Sitemapの参照先が https://{subdomain}.example.com/sitemap.xml となるため、各サブドメインごとに正しいsitemapへ誘導できる。

Step 10: OG画像プレースホルダー

SNSでシェアされたときのOG画像として、業種ごとのSVGファイルを作成した。

public/og/
  industry-a.svg
  industry-b.svg
  industry-c.svg
  industry-d.svg
  industry-e.svg

SVGを採用した理由は、テキストベースで業種名を動的に表示できること。ただしSNSプラットフォーム(Twitter/X、Facebook等)はSVG形式のOG画像を直接サポートしていないため、あくまでプレースホルダーとしての位置付けで、後からPNGに差し替える想定。

SVGの内容は業種名(1行目、大きめフォント)とサブタイトル(2行目、やや小さめ・太字)、事務所名をレイアウトしたシンプルなもの。

当初サブタイトルは「特化した税務サポート」としていたが、「カタカナを避けたい」というフィードバックを受けて「向け税務支援」に変更した。

名称統一の作業

SEO実装と並行して、コンテンツ全体の名称統一作業が発生した。

サービス名の削除

ドメインはそのまま使うが、サイト上のコンテンツ表示からは旧サービス名を削除し、正式な事務所名に統一する方針になった。

対象箇所はかなり多い。

  • seo.ts のサイト名
  • 各業種の index.mdmeta.title
  • フォールバック設定の meta.title
  • 各Vueコンポーネントのタイトルサフィックス

composableで一元管理していたので、seo.tsofficeName を変更するだけで大部分は対応できた。ただし、コンテンツファイルのfrontmatterやSVGファイルはcomposable経由ではないため、個別に修正が必要だった。

代表者名の修正

作業の途中で代表者のフルネームが確定し、仮名から正式名に修正する工程が入った。useBusinessInfo() composableに集約済みだったため、seo.ts の1箇所を直すだけで全コンポーネントに反映された。ただし、コンテンツファイルのfrontmatterとSVGファイルは別途grepして置換した。

住所情報の反映

事務所の住所が確定し、seo.tsBUSINESS_INFO に反映した。構造化データの PostalAddress もこのデータを参照するため、一箇所の変更で JSON-LD にも反映される。

「専門」から「特化」への表現修正

「{業種}専門の税理士」のような表現だと「その業種しかやっていない」という印象を与えるため、「{業種}に特化した税理士」に変更した。ただし「専門家」「専門知識」「専門性」のように「エキスパート」の意味で使っている箇所はそのまま残した。

対象ファイルを洗い出す際は、以下のような判断基準で分類した。

  • 「{業種}専門の税理士」 -> 「{業種}に特化した税理士」に変更
  • 「税務の専門家」 -> そのまま(エキスパートの意味)
  • 「専門知識を活かして」 -> そのまま

事務所情報の一元管理リファクタ

作業を進めるうちに、事務所名・住所・代表者名がコンポーネントごとにハードコードされている問題が目立つようになった。修正が入るたびに全ファイルをgrepして置換するのは現実的ではないため、useBusinessInfo() composableを作って一元管理する方針に切り替えた。

// composables/useBusinessInfo.ts(概要)
export const useBusinessInfo = () => {
  return {
    name: BUSINESS_INFO.name,
    officeName: BUSINESS_INFO.officeName,
    postalCode: BUSINESS_INFO.postalCode,
    address: BUSINESS_INFO.address,
    email: BUSINESS_INFO.email,
    // ...
  }
}

各コンポーネント(Footer、Contact、Profile、Flow等)からハードコードされた事務所名・住所を削除し、useBusinessInfo() の返り値を使うようにリファクタした。

このリファクタのおかげで、代表者名が修正されたとき、seo.ts の1行を変更するだけで全コンポーネントに反映できた。

ビルドエラーと対処

queryCollectionWithEvent が見つからない

sitemap.xml.ts で @nuxt/content から queryCollectionWithEvent をインポートしようとしたところ、ビルドエラーが発生した。

Nuxt Content v3ではサーバーサイドのAPIが変更されており、正しくは @nuxt/content/server から queryCollection をインポートし、第1引数に event を渡す形になる。

// NG
import { queryCollectionWithEvent } from '@nuxt/content'

// OK
import { queryCollection } from '@nuxt/content/server'
// 使用時: queryCollection(event, 'pages')

修正後はビルドが通り、devサーバーでもsitemap.xmlが正常に生成されることを確認した。

確認結果

devサーバーを起動して、HTMLソースのmetaタグを確認した。

  • <link rel="canonical" href="https://industry-a.example.com/"> -- canonical URL
  • <meta property="og:locale" content="ja_JP"> -- OGP locale
  • <meta property="og:site_name" content="事務所名"> -- サイト名
  • <meta name="twitter:card" content="summary_large_image"> -- Twitterカード
  • <script type="application/ld+json"> -- JSON-LD構造化データ

/sitemap.xml にアクセスすると、静的ページと記事の一覧がXML形式で出力される。/robots.txt では Sitemap: ディレクティブが正しいサブドメインURLを指している。

振り返り

10ステップの計画を立ててから実装に入ったことで、作業の抜け漏れなく進められた。途中でユーザーからのフィードバック(名称変更、表現修正等)が複数回入ったが、事務所情報をcomposableで一元管理していたおかげで、修正の影響範囲が限定的だった。

SVGのOG画像はあくまでプレースホルダーなので、本番運用前にPNGへの差し替えが必要。Cloudflare WorkersでSVGからPNGへのオンザフライ変換を行う方法もあるが、OG画像の更新頻度を考えると静的PNGで十分だろう。

全体を通して、マルチテナント構成でのSEO実装は「業種ごとに異なる値をどう管理するか」が肝になる。定数ファイルとcomposableで業種情報を一元管理し、各ページコンポーネントではそれを参照するだけの構成にしたことで、新しい業種を追加する際もデータを1箇所追加するだけで済む設計になった。