• Nuxt
  • SEO
  • Cloudflare
  • パフォーマンス

Nuxt 4 のポートフォリオを完全静的にして Cloudflare Workers に載せるまで — SEO のためにやったこと全部

このサイト自体の話。Nuxt 4 を prerender で全ページ静的化し、title / canonical / JSON-LD / sitemap を仕込み、supabase-js を外して JS を 37% 減らし、日本語 Web フォントを捨てて CSS を半分にした。Cloudflare Workers Builds へのデプロイと _headers の落とし穴も。

公開: 約 9 分namespace

このサイトを作り直したときの記録です。目的は「フリーランスの IT コンサル / エンジニアとして検索で見つけてもらう」ことなので、見た目より先に 完全静的・速い・構造化データが揃っている を優先しました。Nuxt 4 で何をどう設定したかを、順番に書きます。

1. 全ページを prerender する

SSR で毎回レンダリングする理由がないので、全ページを静的 HTML に書き出します。routeRulesnitro.prerender を両方書いておくと確実です。

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/': { prerender: true },
    '/works/**': { prerender: true },
    '/services/**': { prerender: true }
  },
  nitro: {
    prerender: {
      crawlLinks: true,   // リンクを辿って見つけたページも書き出す
      routes: ['/', '/works', '/services', '/sitemap.xml', '/robots.txt']
    }
  }
})

crawlLinks: true があると、ページ内のリンクから他のページを自動で見つけてくれます。ただし「どこからもリンクされていないページ」は見つからないので、routes にも並べておきます。

2. sitemap.xml と robots.txt はサーバールートで生成する

モジュールを足さなくても、server/routes/sitemap.xml.ts を置けば prerender の対象にできます。サイトの URL は環境変数から読むので、本番と開発で正しい URL が出ます。

// server/routes/sitemap.xml.ts
const routes = [
  { path: '/', priority: '1.0', changefreq: 'weekly' },
  { path: '/services', priority: '0.9', changefreq: 'monthly' },
  // ...
]

export default defineEventHandler((event) => {
  const siteUrl = useRuntimeConfig(event).public.siteUrl
  setHeader(event, 'Content-Type', 'application/xml; charset=utf-8')
  return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${routes.map(r => `  <url><loc>${siteUrl}${r.path}</loc><priority>${r.priority}</priority></url>`).join('\n')}
</urlset>`
})

ページを足したらここにも足す、というルールにしておきます。

3. title / description / canonical / OG / JSON-LD を 1 つの composable にまとめる

ページごとに useHeaduseSeoMeta を書くと漏れるので、composable にしました。

// app/composables/useSiteSeo.ts
export function useSiteSeo(options: { title?: string, description: string, path: string, image?: string }) {
  const siteUrl = useRuntimeConfig().public.siteUrl
  const url = `${siteUrl}${options.path}`
  const image = `${siteUrl}${options.image ?? '/og/default.png'}`

  useHead({ title: options.title ?? null, link: [{ rel: 'canonical', href: url }] })
  useSeoMeta({
    description: options.description,
    ogTitle: options.title, ogDescription: options.description,
    ogUrl: url, ogImage: image,
    twitterCard: 'summary_large_image', twitterImage: image
  })
  return { url, siteUrl }
}

export function useJsonLd(data: Record<string, unknown>[]) {
  useHead({ script: data.map(item => ({ type: 'application/ld+json', innerHTML: JSON.stringify(item) })) })
}

JSON-LD は、トップに ProfessionalService / WebSite / FAQPage、サービスページに Service(OfferCatalog 付き)/ FAQPage / BreadcrumbList、実績ページに SoftwareApplication、ブログに BlogPosting を出しています。Google の「リッチリザルト テスト」で 1 ページずつ確認しました。

4. キーワードごとにページを分ける

SEO で一番効いたのは、設定ではなくページ構成です。最初は 1 ページのポートフォリオでしたが、「フリーランス IT コンサルタント」「Snowflake データ基盤構築」「業務自動化 スクレイピング」「AI 開発 RAG」のように、狙う語ごとに専用ページを作りました。各ページに H1・課題・提供内容・FAQ・関連実績を置き、内容のあるページにしています。

1 ページに全部詰めると、どの語でも中途半端になります。ページを分けて、それぞれの title と H1 に狙う語を入れる。地味ですが、これが土台です。

5. JS を減らす:supabase-js を捨てて fetch にする

お問い合わせフォームのために @nuxtjs/supabase を入れていましたが、やっていることは contacts テーブルへの INSERT 1 回だけ。そのために supabase-js を丸ごとバンドルするのは重すぎるので、PostgREST を直接叩くようにしました。

await fetch(`${supabaseUrl}/rest/v1/contacts`, {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'apikey': supabaseKey,
    'Authorization': `Bearer ${supabaseKey}`,
    'Prefer': 'return=minimal'
  },
  body: JSON.stringify({ name, email, message })
})

これでメインの JS チャンクが 590KB → 373KB(約 37% 減)になりました。anon key はもともとクライアントに出るものなので、セキュリティ上の差はありません(INSERT は RLS のポリシーで守ります)。

6. CSS を半分にする:日本語 Web フォントを見出し限定にする → 結局やめる

最初は Zen Kaku Gothic New を本文にも使っていました。ビルドしたら CSS が 648KB。中身を見ると @font-face が 591 個。日本語フォントは unicode-range で 120 個に分割されていて、それが 4 ウェイト分あったからです。

見出し用に 2 ウェイトだけ残すと 440KB、最終的に日本語 Web フォントをやめて欧文だけ Inter にしたら 212KB になりました。PayPay のスタイルガイドが「和文はシステムフォント」としているのと同じ判断です。日本語 Web フォントは、見た目の差に対してコストが大きすぎます。

7. Cloudflare Workers Builds にデプロイする

Cloudflare のダッシュボードで GitHub のリポジトリを接続すると、いまは Pages ではなく Workers のフローになります。Build command を pnpm run build にすると、Nuxt が cloudflare-module preset を自動で選び、prerender したページを静的アセットとして配信してくれます。Deploy command は既定の npx wrangler deploy のままで動きます。

注意点は public/_headers のパターンです。/works/*max-age=31536000, immutable を付けたつもりが、/works/index.html にもマッチして HTML が 1 年キャッシュされるところでした。画像なら /works/finoko/*.webp のように拡張子まで絞ります。

8. Lighthouse で言われたことを潰す

  • 画像が表示サイズより大きい → 280 / 420 / 560px の 3 サイズを用意し srcsetsizes で出し分け。sharp が無い環境だったので Playwright の Chromium で canvas に描いて縮小しました
  • LCP が取れない → ファーストビューの要素に「スクロールで出現」アニメーション(初期 opacity: 0)を付けていたのが原因。opacity 0 の要素は LCP 候補にならないので、ファーストビューは最初から表示する
  • フォントがクリティカルパス → Inter のウェイトを 400 / 500 / 700 に絞る

まとめ:やったことリスト

  1. 全ページ prerender(routeRules + nitro.prerender
  2. sitemap.xml / robots.txt をサーバールートで生成
  3. SEO メタと JSON-LD を composable に集約
  4. 狙うキーワードごとにページを分ける
  5. supabase-js を fetch に置き換えて JS を 37% 減
  6. 日本語 Web フォントをやめて CSS を 648KB → 212KB
  7. Cloudflare Workers Builds でデプロイ、_headers のパターンに注意
  8. Lighthouse の指摘(画像サイズ・LCP・フォント)を潰す

設定の話はここまでですが、本当に効くのは「検索する人の疑問に答えるページがあるか」です。このブログもその一環として始めました。

About the author

namespace

namespace(ネームスペース)

フリーランスの IT コンサルタント兼エンジニア。基幹システム刷新・データ基盤・FA から、Web / SaaS 開発、業務自動化、AI 開発まで一気通貫で支援しています。 相談する →

More posts