- 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 に書き出します。routeRules と nitro.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 にまとめる
ページごとに useHead と useSeoMeta を書くと漏れるので、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 サイズを用意し
srcsetとsizesで出し分け。sharp が無い環境だったので Playwright の Chromium で canvas に描いて縮小しました - LCP が取れない → ファーストビューの要素に「スクロールで出現」アニメーション(初期
opacity: 0)を付けていたのが原因。opacity 0 の要素は LCP 候補にならないので、ファーストビューは最初から表示する - フォントがクリティカルパス → Inter のウェイトを 400 / 500 / 700 に絞る
まとめ:やったことリスト
- 全ページ prerender(
routeRules+nitro.prerender) - sitemap.xml / robots.txt をサーバールートで生成
- SEO メタと JSON-LD を composable に集約
- 狙うキーワードごとにページを分ける
- supabase-js を fetch に置き換えて JS を 37% 減
- 日本語 Web フォントをやめて CSS を 648KB → 212KB
- Cloudflare Workers Builds でデプロイ、
_headersのパターンに注意 - Lighthouse の指摘(画像サイズ・LCP・フォント)を潰す
設定の話はここまでですが、本当に効くのは「検索する人の疑問に答えるページがあるか」です。このブログもその一環として始めました。
About the author
namespace(ネームスペース)
フリーランスの IT コンサルタント兼エンジニア。基幹システム刷新・データ基盤・FA から、Web / SaaS 開発、業務自動化、AI 開発まで一気通貫で支援しています。 相談する →
More posts