• #OGP
  • #Cloudflare Workers
開発完了

OG Worker拡張計画: Vueページ対応

結論(実績)

ビルド時間を5分22秒から3分31秒に短縮(1分51秒、約35%改善)

項目BeforeAfter改善
総ビルド時間5分22秒3分31秒-1分51秒
Generate static site175秒150秒-25秒
og-image prerender262件0件-262件

実施内容

  1. OG Workerを拡張: Vueページ(coding-standards, jleague等)のOGP画像を動的生成
  2. defineOgImage削除: 全44ファイルからnuxt-og-imageのprerender呼び出しを削除
  3. usePageOgSignature導入: サーバサイドで署名付きOG Worker URLを生成

これにより、ビルド時のOGP画像生成が不要となり、初回アクセス時にWorkerが動的生成→R2キャッシュする方式に変更された。

背景・課題

現状のビルド時間内訳(5分22秒)

フェーズ時間
Install dependencies23秒
Content processing18秒
Vite Client build37秒
Vite Server build53秒
Prerendering175秒(約3分)

Prerender内訳(1228ページ)

カテゴリ説明
blog-article392記事HTML + _payload.json
og-image262OGP画像生成(削減対象)
coding-standards160現時点で158ルール + ページ2件(index, viewer/index)
financial-quiz137クイズ + J.League + payload
その他277docs, blog一覧, payload等

nuxt-og-imageが262件のOGP画像をビルド時にprerenderしている。

目標

  • __og-image__のprerenderを0にする(262件削減)
  • ビルド時間を4分以下に短縮

既存ファイルの場所

Worker関連

  • Worker本体: apps/workers/og/src/index.ts
  • wrangler.toml: apps/workers/og/wrangler.toml

Nuxt側

  • 既存のOG署名composable: apps/web/app/composables/useOgSignature.ts
  • nuxt.config.ts: apps/web/nuxt.config.ts

defineOgImageを使用しているファイル(44件)

以下のファイルからdefineOgImageを削除し、useOgSignatureに置き換える:

coding-standards(3件)

  • app/pages/coding-standards/index.vue
  • app/pages/coding-standards/viewer/index.vue
  • app/pages/coding-standards/viewer/[ruleId].vue

financial-quiz(13件)

  • app/pages/financial-quiz/index.vue
  • app/pages/financial-quiz/actual-consensus.vue
  • app/pages/financial-quiz/analyst-conservatism.vue
  • app/pages/financial-quiz/balance-sheet-test.vue
  • app/pages/financial-quiz/eps-per-scatter.vue
  • app/pages/financial-quiz/micron-eps-forecast.vue
  • app/pages/financial-quiz/nvidia-eps-forecast.vue
  • app/pages/financial-quiz/proportional-animation.vue
  • app/pages/financial-quiz/proportional-comparison.vue
  • app/pages/financial-quiz/quiz.vue
  • app/pages/financial-quiz/semiconductor-revenue.vue
  • app/pages/financial-quiz/waterfall-test.vue
  • app/pages/financial-quiz/jleague/index.vue
  • app/pages/financial-quiz/jleague/club/[club].vue

financial-data(4件)

  • app/pages/financial-data/index.vue
  • app/pages/financial-data/annual-revenue-chart.vue
  • app/pages/financial-data/hyperscale-capex.vue
  • app/pages/financial-data/nvidia-financial-chart.vue

blog(14件)

  • app/pages/blog/index.vue
  • app/pages/blog/ai-native-future-scenarios.vue
  • app/pages/blog/blood-medicine-world.vue
  • app/pages/blog/blood-type-basics.vue
  • app/pages/blog/brownian_motion_infographic.vue
  • app/pages/blog/company_chart_absolute.vue
  • app/pages/blog/data-value-breakthrough-analysis.vue
  • app/pages/blog/drawio-viewer-test.vue
  • app/pages/blog/investment-critical-point.vue
  • app/pages/blog/nvidia-moat.vue
  • app/pages/blog/openai-vs-google-considerations.vue
  • app/pages/blog/scenario4-tipping-point.vue
  • app/pages/blog/search_market_analysis.vue
  • app/pages/blog/tax-accountant-inquiry.vue
  • app/pages/blog/tax-consultation-form.vue

その他(10件)

  • app/pages/index.vue
  • app/pages/about.vue
  • app/pages/search.vue
  • app/pages/excel-viewer.vue
  • app/pages/animation-demo.vue
  • app/pages/animation-demo-1.vue
  • app/pages/animation-demo-2.vue
  • app/pages/flowchart/customer-type-identification.vue

解決策

OG Workerを拡張してVueページのOGP画像も生成する。

対象ページ

  1. coding-standards(158ルール)
    • URL: /coding-standards/viewer/rule-X-X
    • パラメータ: title
  2. jleague(62クラブ)
    • URL: /financial-quiz/jleague/club/clubId
    • パラメータ: clubName, clubColor
  3. その他Vueページ(44ページ)
    • 各ページで個別にOG Worker URLを設定

アーキテクチャ

Before(現状)

Vueページ
  └─ defineOgImage(component: OgImageTemplate, ...)
     └─ nuxt-og-imageがビルド時にprerender(262件)

After(改善後)

Vueページ                               OG Worker
  │                                        │
  │ og:image URL を Worker に向ける        │
  │ /og/pages/coding-standards/...         │
  │ /og/pages/jleague/...                  │
  └────────────────────────────────────────│
                                           │
                                           ▼
                                 ┌─────────────────┐
                                 │ R2キャッシュ確認 │
                                 └────────┬────────┘
                                          │
                       ┌──────────────────┴──────────────────┐
                       │                                     │
                       ▼                                     ▼
                  [HIT] R2から返す              [MISS] 生成→R2保存→返す

設計詳細

キャッシュ無効化戦略

キャッシュキーに署名から算出したバージョンを含め、パラメータが変わった場合に自動的に新しい画像を生成する。

キャッシュキー形式:
  pages/coding-standards/{ruleId}/{sig.slice(0,8)}.png
  pages/jleague/{clubId}/{sig.slice(0,8)}.png

バージョンはWorker側で署名の先頭8文字から計算。URLにvパラメータは不要(署名から一意に決まる)。パラメータ(title, name, color)が変わればハッシュも変わるため、自動的にキャッシュが無効化される。

署名検証ルール

既存のブログ記事用署名と同じロジックを使用。

正規化ルール(Worker側・Nuxt側で同一実装):

function normalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ')
}

署名生成:

  • ペイロード: type|id|title(またはtype|id|name|color)をパイプ区切り
  • HMAC-SHA256でハッシュ化、先頭16文字を使用

サニタイズ(Worker側のみ、画像生成時):

  • HTMLエスケープ(&, <, >, ", ')
  • 長さ制限(title: 80文字、clubName: 40文字)

clubColorバリデーション(Worker側):

function isValidHexColor(color: string): boolean {
  return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)
}

不正な形式の場合はデフォルト色(#1a1a2e)を使用。

nuxt-og-image無効化方針

defineOgImageを使用している全てのVueページから削除し、useHeadでOG Worker URLを設定する。nuxt-og-imageモジュール自体は残すが、prerenderされるページがなくなるため__og-image__は0件になる。

対象ファイル(44ファイル):

  • coding-standards: 3ファイル(index, viewer/index, viewer/ruleId
  • financial-quiz: 約15ファイル(各クイズページ + jleague関連)
  • その他: 約26ファイル(about, search, blog関連等)

OG Worker エンドポイント設計

1. coding-standards用

GET /og/pages/coding-standards/{ruleId}.png
    ?title=ルールのタイトル
    &sig=署名(16文字)

デザイン: ピンク→青グラデーション、白カード、タイトル中央配置(既存OgImageTemplateと同じ)

2. jleague用

GET /og/pages/jleague/{clubId}.png
    ?name=クラブ名
    &color=クラブカラー(hex、#RGB or #RRGGBB形式)
    &sig=署名(16文字)

デザイン: クラブカラー→暗い青グラデーション、盾アイコン、クラブ名(既存JLeagueOgImageと同じ)

3. 汎用ページ用

GET /og/pages/general/{pageId}.png
    ?title=ページタイトル
    &sig=署名(16文字)

デザイン: OgImageTemplateと同じ

実装コード

Worker (src/index.ts) 追加部分

// 正規化関数(既存と共通、Nuxt側と同一実装)
function normalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ')
}

// サニタイズ関数(既存と共通)
function sanitize(text: string, maxLen: number): string {
  const escaped = text
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;')
  return escaped.length > maxLen ? escaped.slice(0, maxLen - 3) + '...' : escaped
}

// clubColorバリデーション(CSS注入防止)
function isValidHexColor(color: string): boolean {
  return /^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/.test(color)
}

// ページ用署名検証
async function verifyPageSignature(
  params: { type: string; id: string; [key: string]: string },
  signature: string,
  secret: string
): Promise<boolean> {
  const encoder = new TextEncoder()

  // type別にペイロード構築
  let payload: string
  if (params.type === 'coding-standards' || params.type === 'general') {
    payload = [params.type, params.id, normalizeText(params.title || '')].join('|')
  } else if (params.type === 'jleague') {
    payload = [params.type, params.id, normalizeText(params.name || ''), params.color || ''].join('|')
  } else {
    return false
  }

  const key = await crypto.subtle.importKey(
    'raw',
    encoder.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign']
  )
  const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(payload))
  const expected = Array.from(new Uint8Array(sig))
    .map(b => b.toString(16).padStart(2, '0'))
    .join('')
    .slice(0, 16)

  return expected === signature
}

// 既存のルーティングに追加
if (url.pathname.startsWith('/og/pages/coding-standards/')) {
  return handleCodingStandardsOgImage(url, env)
}
if (url.pathname.startsWith('/og/pages/jleague/')) {
  return handleJLeagueOgImage(url, env)
}
if (url.pathname.startsWith('/og/pages/general/')) {
  return handleGeneralPageOgImage(url, env)
}

// coding-standards OGP画像生成
async function handleCodingStandardsOgImage(url: URL, env: Env): Promise<Response> {
  const match = url.pathname.match(/^\/og\/pages\/coding-standards\/(.+)\.png$/)
  if (!match) return new Response('Invalid path', { status: 400 })

  const ruleId = match[1]
  const title = url.searchParams.get('title') || ruleId
  const signature = url.searchParams.get('sig')

  // 署名検証
  if (!signature || !(await verifyPageSignature(
    { type: 'coding-standards', id: ruleId, title },
    signature,
    env.OG_SECRET
  ))) {
    return new Response('Forbidden', { status: 403 })
  }

  // バージョンは署名から算出(URLパラメータに依存しない)
  const version = signature.slice(0, 8)

  // R2キャッシュ確認
  const cacheKey = `pages/coding-standards/${ruleId}/${version}.png`
  const cached = await env.OG_IMAGES.get(cacheKey)
  if (cached) {
    return new Response(cached.body, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=31536000, immutable',
        'X-OG-Cache': 'HIT'
      }
    })
  }

  // 生成
  const imageResponse = await generateOgImageTemplate(sanitize(title, 80))
  const imageBuffer = await imageResponse.arrayBuffer()

  // R2保存
  await env.OG_IMAGES.put(cacheKey, imageBuffer, {
    httpMetadata: { contentType: 'image/png' }
  })

  return new Response(imageBuffer, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
      'X-OG-Cache': 'MISS'
    }
  })
}

// JLeague OGP画像生成
async function handleJLeagueOgImage(url: URL, env: Env): Promise<Response> {
  const match = url.pathname.match(/^\/og\/pages\/jleague\/(.+)\.png$/)
  if (!match) return new Response('Invalid path', { status: 400 })

  const clubId = match[1]
  const clubName = url.searchParams.get('name') || clubId
  const rawColor = url.searchParams.get('color') || '#1a1a2e'
  const signature = url.searchParams.get('sig')

  // clubColorバリデーション(CSS注入防止)
  const clubColor = isValidHexColor(rawColor) ? rawColor : '#1a1a2e'

  // 署名検証
  if (!signature || !(await verifyPageSignature(
    { type: 'jleague', id: clubId, name: clubName, color: rawColor },
    signature,
    env.OG_SECRET
  ))) {
    return new Response('Forbidden', { status: 403 })
  }

  // バージョンは署名から算出(URLパラメータに依存しない)
  const version = signature.slice(0, 8)

  // R2キャッシュ確認
  const cacheKey = `pages/jleague/${clubId}/${version}.png`
  const cached = await env.OG_IMAGES.get(cacheKey)
  if (cached) {
    return new Response(cached.body, {
      headers: {
        'Content-Type': 'image/png',
        'Cache-Control': 'public, max-age=31536000, immutable',
        'X-OG-Cache': 'HIT'
      }
    })
  }

  // 生成(バリデーション済みclubColorを使用)
  const imageResponse = await generateJLeagueOgImage(sanitize(clubName, 40), clubColor)
  const imageBuffer = await imageResponse.arrayBuffer()

  // R2保存
  await env.OG_IMAGES.put(cacheKey, imageBuffer, {
    httpMetadata: { contentType: 'image/png' }
  })

  return new Response(imageBuffer, {
    headers: {
      'Content-Type': 'image/png',
      'Cache-Control': 'public, max-age=31536000, immutable',
      'X-OG-Cache': 'MISS'
    }
  })
}

// OgImageTemplate相当のデザイン
async function generateOgImageTemplate(title: string): Promise<ImageResponse> {
  const fontData = await loadGoogleFont({
    family: 'Noto Sans JP',
    weight: 700,
    text: title + 'Eurekapu.com'
  })

  const html = `
    <div style="display: flex; width: 1200px; height: 630px; justify-content: center; align-items: center; padding: 40px; background: linear-gradient(135deg, #ff0080 0%, #0066ff 100%); font-family: Noto Sans JP, sans-serif;">
      <div style="display: flex; flex-direction: column; justify-content: space-between; width: 1100px; height: 530px; background: white; border-radius: 24px; padding: 50px 60px; box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15);">
        <div style="display: flex; flex: 1; align-items: center; justify-content: center; font-size: 52px; font-weight: 700; color: #1a1a1a; line-height: 1.4; text-align: center;">
          ${title}
        </div>
        <div style="display: flex; justify-content: flex-end; align-items: center;">
          <div style="display: flex; font-size: 24px; color: #888; font-weight: 500;">
            Eurekapu.com
          </div>
        </div>
      </div>
    </div>
  `

  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
  })
}

// JLeagueOgImage相当のデザイン
async function generateJLeagueOgImage(clubName: string, clubColor: string): Promise<ImageResponse> {
  const fontData = await loadGoogleFont({
    family: 'Noto Sans JP',
    weight: 700,
    text: clubName + '財務データ分析J.LEAGUElog.eurekapu.com'
  })

  const html = `
    <div style="display: flex; width: 1200px; height: 630px; flex-direction: column; justify-content: center; align-items: center; padding: 60px; position: relative; font-family: Noto Sans JP, sans-serif; background: linear-gradient(135deg, ${clubColor} 0%, #1a1a2e 100%);">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" style="width: 120px; height: 120px; margin-bottom: 32px;">
        <path d="M50 5 L90 20 L90 50 Q90 80 50 95 Q10 80 10 50 L10 20 Z" fill="${clubColor}" stroke="white" stroke-width="3"/>
        <circle cx="50" cy="50" r="20" fill="white"/>
        <path d="M50 30 L55 42 L68 42 L58 50 L62 63 L50 55 L38 63 L42 50 L32 42 L45 42 Z" fill="${clubColor}"/>
      </svg>
      <div style="display: flex; font-size: 64px; font-weight: 700; color: white; text-align: center; line-height: 1.3; max-width: 1000px; text-shadow: 2px 2px 8px rgba(0,0,0,0.5);">
        ${clubName}
      </div>
      <div style="display: flex; font-size: 32px; font-weight: 500; color: rgba(255, 255, 255, 0.9); margin-top: 24px; text-align: center;">
        財務データ分析
      </div>
      <div style="display: flex; position: absolute; bottom: 40px; right: 60px; font-size: 24px; color: rgba(255, 255, 255, 0.8); font-weight: 500;">
        log.eurekapu.com
      </div>
      <div style="display: flex; position: absolute; top: 40px; left: 60px; font-size: 20px; color: rgba(255, 255, 255, 0.7); font-weight: 600; letter-spacing: 2px;">
        J.LEAGUE
      </div>
    </div>
  `

  return new ImageResponse(html, {
    width: 1200,
    height: 630,
    fonts: [{ name: 'Noto Sans JP', data: fontData, weight: 700, style: 'normal' }]
  })
}

wrangler.toml 追加

routes = [
  { pattern = "log.eurekapu.com/share/*", zone_name = "eurekapu.com" },
  { pattern = "log.eurekapu.com/og/blog/*", zone_name = "eurekapu.com" },
  { pattern = "log.eurekapu.com/og/pages/*", zone_name = "eurekapu.com" }
]

Nuxt側 composable追加 (app/composables/useOgSignature.server.ts)

重要: このプロジェクトはcloudflare-pages-static(静的サイト)前提です。静的ビルドでは動的APIは動作しないため、.server.tsサフィックスを使用してビルド時(SSG)にサーバサイドで署名を生成します。

  • .server.tsサフィックスにより、このファイルはサーバサイドでのみバンドルされる
  • SSGビルド時にサーバサイドで実行され、結果がHTMLに埋め込まれる
  • クライアントバンドルには含まれない(シークレットの漏洩防止)
// app/composables/useOgSignature.server.ts
// .server.ts サフィックスにより、このファイルはサーバサイドでのみバンドルされる
import { createHmac } from 'node:crypto'

// 正規化関数(Worker側と同一実装)
function normalizeText(text: string): string {
  return text.trim().replace(/\s+/g, ' ')
}

// 署名生成(サーバサイド専用)
function generatePageSignature(params: {
  type: string
  id: string
  title?: string
  name?: string
  color?: string
}): string {
  // useRuntimeConfigはサーバサイドでのみOG_SECRETを返す
  const config = useRuntimeConfig()
  const secret = config.ogSecret
  if (!secret) throw new Error('OG_SECRET is not configured in runtimeConfig')

  let payload: string
  if (params.type === 'coding-standards' || params.type === 'general') {
    payload = [params.type, params.id, normalizeText(params.title || '')].join('|')
  } else if (params.type === 'jleague') {
    payload = [params.type, params.id, normalizeText(params.name || ''), params.color || ''].join('|')
  } else {
    throw new Error('Unknown type')
  }

  return createHmac('sha256', secret).update(payload).digest('hex').slice(0, 16)
}

// ページ用OGP画像URL生成(サーバサイド専用)
export function useOgSignature() {
  return {
    generatePageOgImageUrl(params: {
      type: 'coding-standards' | 'jleague' | 'general'
      id: string
      title?: string
      clubName?: string
      clubColor?: string
    }): string | null {
      // 必須パラメータのバリデーション
      if (params.type === 'coding-standards' || params.type === 'general') {
        if (!params.title) return null
      } else if (params.type === 'jleague') {
        if (!params.clubName || !params.clubColor) return null
      }

      const baseUrl = 'https://log.eurekapu.com/og/pages'

      if (params.type === 'coding-standards') {
        const sig = generatePageSignature({ type: 'coding-standards', id: params.id, title: params.title })
        return `${baseUrl}/coding-standards/${params.id}.png?title=${encodeURIComponent(params.title!)}&sig=${sig}`
      }

      if (params.type === 'jleague') {
        const sig = generatePageSignature({ type: 'jleague', id: params.id, name: params.clubName, color: params.clubColor })
        return `${baseUrl}/jleague/${params.id}.png?name=${encodeURIComponent(params.clubName!)}&color=${encodeURIComponent(params.clubColor!)}&sig=${sig}`
      }

      // general
      const sig = generatePageSignature({ type: 'general', id: params.id, title: params.title })
      return `${baseUrl}/general/${params.id}.png?title=${encodeURIComponent(params.title!)}&sig=${sig}`
    }
  }
}

nuxt.config.tsにruntimeConfig追加:

export default defineNuxtConfig({
  runtimeConfig: {
    ogSecret: process.env.OG_SECRET, // サーバサイドのみ
  }
})

Vueページ側の変更例

注意: useOgSignature().server.tsファイルのためサーバサイド専用。クライアント側で呼ばれるとビルドエラーになるため、useAsyncDataserver: trueオプションで包み、サーバサイドでのみ実行されることを保証する。

ruleId.vue

// Before
defineOgImage({
  component: 'OgImageTemplate',
  title: computed(() => currentRule.value?.title || 'JavaScriptコーディング規約')
})

// After
const route = useRoute()
const ruleIdValue = String(route.params.ruleId)

// サーバサイドでのみ実行(SSGビルド時に署名生成、結果はHTMLに埋め込まれる)
const { data: ogImageUrl } = await useAsyncData(
  `og-coding-standards-${ruleIdValue}`,
  () => {
    const { generatePageOgImageUrl } = useOgSignature()
    if (!currentRule.value) return null
    return generatePageOgImageUrl({
      type: 'coding-standards',
      id: ruleIdValue,
      title: currentRule.value.title
    })
  },
  { server: true, lazy: false }
)

useHead({
  meta: computed(() => ogImageUrl.value ? [
    { property: 'og:image', content: ogImageUrl.value },
    { property: 'og:image:width', content: '1200' },
    { property: 'og:image:height', content: '630' },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:image', content: ogImageUrl.value },
  ] : [])
})

club.vue

// Before
defineOgImage({
  component: 'JLeagueOgImage',
  clubName: selectedClub.value?.name ?? 'Jリーグ',
  clubColor: clubColor.value,
})

// After
const route = useRoute()
const clubIdValue = String(route.params.club)

// サーバサイドでのみ実行(SSGビルド時に署名生成、結果はHTMLに埋め込まれる)
const { data: ogImageUrl } = await useAsyncData(
  `og-jleague-${clubIdValue}`,
  () => {
    const { generatePageOgImageUrl } = useOgSignature()
    if (!selectedClub.value) return null
    return generatePageOgImageUrl({
      type: 'jleague',
      id: clubIdValue,
      clubName: selectedClub.value.name,
      clubColor: clubColor.value
    })
  },
  { server: true, lazy: false }
)

useHead({
  meta: computed(() => ogImageUrl.value ? [
    { property: 'og:image', content: ogImageUrl.value },
    { property: 'og:image:width', content: '1200' },
    { property: 'og:image:height', content: '630' },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:image', content: ogImageUrl.value },
  ] : [])
})

補足: SSR対応プロジェクトの場合

SSR対応プロジェクト(cloudflare-pagesなど動的サーバーを使用する場合)では、以下の方法も可能です:

  1. server/api/og-signature.get.tsを作成し、API経由で署名を取得
  2. Vueページ側でuseFetch('/api/og-signature', ...)を使用

ただし、本プロジェクトは静的サイト前提のため、上記の.server.ts方式を採用しています。

実装手順

  1. Worker実装
    • normalizeText, sanitize, isValidHexColor関数追加
    • verifyPageSignature関数追加
    • /og/pages/coding-standards/ エンドポイント追加
    • /og/pages/jleague/ エンドポイント追加
    • /og/pages/general/ エンドポイント追加
    • R2キャッシュ処理(署名からバージョン算出)
  2. wrangler.toml更新(ルート追加)
  3. ローカルテスト(wrangler dev --remote)
  4. Workerデプロイ
  5. Nuxt側実装
    • nuxt.config.tsにruntimeConfig.ogSecret追加(既に設定済み)
    • app/composables/usePageOgSignature.ts作成(動的インポートで署名生成)
    • ruleId.vue修正(defineOgImage削除、usePageOgSignature + useHead追加)
    • club.vue修正(defineOgImage削除、usePageOgSignature + useHead追加)
    • その他Vueページ修正(42ファイル)
  6. 全てのdefineOgImage呼び出しを削除
  7. ビルドテスト
  8. 本番デプロイ・動作確認(2026-01-01 完了)

期待効果 → 実績

項目Before期待実績
og-image prerender262件0件0件
Generate static site175秒約100秒150秒
総ビルド時間5分22秒約4分3分31秒

期待を上回る結果となった。OGP画像のprerender削除に加え、全体的なビルド最適化が効いている。

リスク・注意点

  1. 初回アクセス遅延: キャッシュなし初回は1-2秒かかる(SNSクローラーには影響なし)
  2. 44ファイルの修正: Vueページの修正が多い(一括置換で対応可能)
  3. デザイン一致: Worker側のHTML/CSSがVueコンポーネントと完全一致する必要あり

設計判断

Q&A

Q: OGPのキャッシュ無効化はupdatedAtなどのバージョンパラメータで行う想定ですか?

A: はい。署名のハッシュ値(8文字)をバージョンとして使用。パラメータ(title, name, color)が変わればハッシュも変わるため、自動的にキャッシュが無効化される。

Q: ruleId/clubIdの値は常に固定で、将来的に改名・統合されない前提ですか?

A: 基本固定。改名する場合はキャッシュキーが変わるだけで問題なし。統合の場合は古いIDのキャッシュは放置(自然に期限切れ or 手動削除)。

Q: nuxt-og-imageは残す前提ですか?

A: モジュール自体は残すが、defineOgImageを全て削除するため__og-image__のprerenderは0件になる。将来的にモジュール削除も検討可能。

関連ドキュメント