Next.jsは個人的にすごく好きなフレームワークなのですが元々SPA向けのため構造上SEOにはそんなに強くないのがネックだが何とか最適化を図って対応していました。
そんな中で今回は看過できない自体が発生したためAstroという別のフレームワークを使って3日ぐらい(一夜城ではない)で何とかするお話です。
環境構成
現行のシステム構成はざっくり以下となり、記事ページとアプリケーションが混在するようなサイトを構築しています。
CDN + Next.js(AppRouter) + microCMS
課題
Googleのサーチコンソールに一部ページが空のページとして登録され検索結果のindexから外れる事象が発生、これはSEOサイトとしては致命的な問題。

原因特定
ページのDOMサイズが大きい?
- 推論:SEO系のページはNext.jsで出力される平均サイズが3~5MBほどなるためbotの記事読み込みでタイムアウトした可能性
- 検証:優先度が低いサイドコンテンツを削り2MBほどに減らして見る
- 検証結果:一部ページは改善されたがしばらくするとまたindexから消える状態になる
キャッシュの見直ししてみる
- 推論:現状キャッシュのTTLを1日にしておりbotのクロールタイミングでキャッシュ切れを起こしオリジンアクセスされるためbotの記事読み込みでのタイムアウト発生の可能性
- 検証:キャッシュTTLを伸ばしSWRでキャッシュ切れを無くす
- 検証結果:結果は変わらず
Next.jsが埋め込む大量のjsやJOSNデータの影響?
- 推論:Next.jsの仕様で大量に分割されたjsファイルとハイドレーションのためのJSONデータがページ内に埋め込まれている、js分割は並列ダウンロードされるためSEO的に有効だが数が多すぎるとbot読み込みで問題がある可能性
- 検証:完全静的なHTMLを作成(Next.js出力したHTMLからjsとJSONデータを除外)
- 検証結果:indexが復活!!効果が認められる
Next.jsのページに埋め込まれている大量のJSONデータについて
ブラウザのページソースを確認するとHTMLの他に同量のJSONデータがHTMLに埋まっていることが見て取れる。

これはNext.jsがハイドレーション用に利用するデータやnext/linkでページ移動する際に読み込むページ情報などが詰まっています。つまり初回ランディングした場合はHTMLの他にこのデータも含まれるため単純計算で2倍以上のサイズをダウンロードする必要があるのです。(next/linkはページリンク時に必要なJSONデータだけ読み込むので高速でページ遷移できるのですが、初回はどうしても遅くなります)これがNext.jsがSPAに特化したフレームワークでSEOに不向きな点です。
対策案
原因特定の検証結果から静的HTML化が有効とわかるので対応案を考えてみる。
(案1)SSRにする
現状のシステムはNext.jsでSSRしているがこれをSSGに変更しても結局埋め込まれるjsの数とJSONデータには影響しなかった。
結果 → 効果無し
(案2)Next.jsの出力からjsとJSONを動的に取り除く
出力されたHTMLをパースしjsとJSONデータを取り除く案を考えたが
これをやるとページ内のCSRが動かなくなるので実用的に無理そう
(案3)Astro案
調べてみると
- routingもNext.jsのAppRouterに近い構成で実装可能
- TSX(JSX)コンポーネントが使える
- tailwindcss対応
- SSG可能
- ビルドが早い(らしい)
- 学習コスト低め
これでいいんじゃないかな?ということでAstro案を進めることにする。
インフラ構成案
いきなりフルリプレイスはリスクが高いのでNext.jsをAstroで置き換えるのではなく同居する形を採用し以下のような運用を想定する。
- 記事ページはAstroでSSGして静的HTMLでホスティング
- 動的ページはNext.jsでSSRして今まで通り運用
その場合のインフラ構成は以下を想定する。
(AstroはGithubのactionsでSSGビルドを行い出力された静的HTMLをS3バケットにアップロードし静的ホスティングを行いAstro用のnodeサーバーは立てない)
AstroをNext.jsと同居させる実装を想定
プロジェクト構成
以下の構成のようにモノレポでNext.jsと並列になるようにAstroの配置を行う。
.
├── apps
│ ├── next_project
│ │ ├── src
│ │ └── ...
│ └── astro_project ← new
│ ├── src
│ └── ...
└── packages
├── tailwind_config
└── ...
実装の流れ
実装戦略
Astroは基本.astroファイルで記述しReactのTSX(JSX)コンポーネントはあくまでもViewパーツとしてimportして.astroファイル内に配置する必要があります。
従って、どこまでがAstro管轄でどこからがNext.jsのReactコンポーネント管轄にするかの線引きを決めておく必要があります。
今回はAstro側の実装では以下の線引きでAstroとNext.jsコンポーネントを棲み分けする計画を立てました。
| ファイルタイプ | 形式 | 理由 |
|---|---|---|
| ページ | .astro | Astroの仕様上.astroファイル以外利用不可のためAstro側に移植 またここでデータフェッチを行い取得データを子コンポーネントに渡していくようにします |
| レイアウト | .astro | Next.jsのTSXコンポーネントをそのまま利用でも問題ないがドロワーメニューなどCSRにする必要があり細かくclient指定が行えるようにAstro側に移植 |
| テンプレート | .astro | ここもCSR対象を細かく制御したいのでAstroに移植 ※Astroは構造上テンプレートという概念は無くレイアウトに集約されるがNext.jsと並行管理がしやすい用あえてこの概念を持ち込んでいる。 |
| 上記意外UI | .tsx | 上記以外は既存のNext.jsのTSXコンポーネントを利用する、ただしCSRコントロールを細かく行いたい場合は.astroで書き直すケースもあります。 |
Astroインストール
公式の通りにインストールでサクッと入ります。
createコマンド実行場所は上記プロジェクト構成に従い/apps配下で行う。
$ cd apps
$ pnpm create astro@latest
ReactとTailwindも使えるようにする
React追加
$ pnpm astro add react
tailwind追加
$ pnpm astro add tailwind
ここまでは公式の通りで他のブログでも紹介しているため詳細は割愛。
エイリアスを通す
今回AstroではNext.jsのコンポーネントをそのまま利用するためAstroからNext.jsプロジェクトに参照できるようにエイリアスを通しておく
エイリアス設定
@は既存のNext.jsプロジェクトで./src配下を指すエイリアスのためAstro側でもNext.jsのコンポーネントファイルを参照できるように設定する。
-
@→../next_project/src/*を参照 -
@astro→./src/*を参照
{
"extends": "astro/tsconfigs/strict",
"include": [
".astro/types.d.ts",
"**/*"
],
"exclude": [
"dist"
],
"compilerOptions": {
"jsx": "react-jsx",
"jsxImportSource": "react",
"paths": {
"@/*": ["../next_project/src/*"],
"@astro/*": ["./src/*"],
},
}
}
import { fileURLToPath } from 'node:url'
...
// https://astro.build/config
export default defineConfig({
...
vite: {
...
resolve: {
alias: {
'@': fileURLToPath(
new URL('../next_project/src', import.meta.url),
),
'@astro': fileURLToPath(new URL('./src', import.meta.url)),
},
extensions: ['.mjs', '.js', '.mts', '.ts', '.jsx', '.tsx', '.json'],
},
...
},
})
エイリアスを通すことで以下のようにAstro側でNext.jsで実装済みコンポーネントをimportのパス形式を保ったまま利用できコンポーネント流用で不要な混乱を避けられる。
---
import { Breadcrumbs } from '@/components/Breadcrumbs' // ← Nextと同じインポートパス
---
<body>
<Breadcrumbs />
</body>
Next.js固有のライブラリの削除
Next.jsで作成したコンポーネントに以下のライブラリを使用していると、Astro側でインポートして利用することができません。
以下のようにNext.jsライブラリを代替する必要があります。
- next/link
-
aタグに変換
-
- next/image
-
imgタグに変換
※ Next.jsの最適化が効かなくなるので自前で最適化するか、AstroのgetImageを使い最適化されたattributeを渡すなどの工夫が必要。
-
- next/navigation
-
window.pushなどJavascript標準のルーティングを使う
-
- next/script
-
scriptタグに変換
-
- next/headers
- 代替案無し実装方法から変える必要あり
- その他
next/*系のライブラリも代替するか削除する必要があります。
SVGへの対応
AstroはSVGをサポートしていますが、ReactのTSX(JSX)コンポーネントでSVGをimportして使っている場合はそのままAstroで利用することができません。
今回はその対応で vite-plugin-svgrプラグインの使用で回避することにしました。
- プラグインインストール
$ pnpm add -D vite-plugin-svgr
- astro.config.mjsの編集
...
import svgr from 'vite-plugin-svgr'
export default defineConfig({
...
vite: {
plugins: [svgr()],
},
})
- ReactのTSX(JSX)コンポーネントのSVGインポートに
?reactsuffixを追加
(このサフィックスをつけてもNext.js側で問題にはならないようです)
import LogoSvg from './logo.svg?react'
...
tailwindcssのビルド対応
意外に困ったのがAstroでNext.jsのTSX(JSX)コンポーネントをインポートして利用時にtailwindcssのビルドが動かないことでした。
このためAstroに配置したNext.jsのコンポーネントにスタイルが適応されず困りました。
(Astro側で作成したTSXコンポーネントのtailwindcssは正しくビルドされスタイルが適応されます)
この対策は以下のようにglobal.cssにNext.js側のコンポーネント情報を登録することで回避できます。
...
@source "../../../next_project/src/**/*.{js,jsx,ts,tsx}";
これで、AstroでビルドするかHMR実行でtailwindのスタイルが適応されるようになりました。
ServerComponentのasync/awaitの排除
AstroでNext.jsのコンポーネントを扱う上で一番の難関がこれです。
Next.jsのRSC(ServerComponent)はasync/awaitに対応しており、コンポーネント内でfetchなどの非同期処理で実行できその結果をレンダリング出来ます。
このためNext.jsでは従来のMVCモデルとは異なり自由にビジネスロジックを含むコンポーネントを配置してページを組み立てることが出来ます。
これは非常に強力で依存度が低いコンポーネント指向を実現できNext.jsの魅力と言っていい機能です。
(ちなみにNext.jsは同一リクエスト内で複数の同じフェッチがあれば一つにまとめる機能があるためこういったコンポーネント毎にフェッチ処理を書いてもパフォーマンスに影響出ないようしてくれています。便利)
ただAstroはこのasync/awaitで定義したRSC(ServerComponent)を利用することは出来ません、TSX(JSX)コンポーネントはあくまでもピュアなViewとして利用する必要があります。
フェッチなどの非同期処理は.astroファイル側で実行しその結果をTSX(JSX)コンポーネントに渡すような形式とする必要があります。
今回はNext.jsとAstroを共存させる必要があるので単純にNext.js側のRSC(ServerComponent)からフェッチなどの非同期処理を取り除くとNext.js側にも大きな改修が必要となります。
ただ、元々非同期なRSC(ServerComponent)は非同期処理を行うHOC(高階コンポーネント)とピュアなコンポーネントに分離して実装していたためNext.js側の変更はピュアなコンポーネントを公開処理(exportを付ける)だけの対応で済みました。この影響がAstroの立ち上げ期間の短縮に繋がっていました。
- Next.js側のHOC対応したRSC(ServerComponent)例
// `article`を受け取って表示するだけのピュアなコンポーネント、Astro側で利用
export const Content = ({ article }: WithFetchArticleProps) => {
return (
<div>
<p>{article.contents}</p>
...
</div>
)
}
// HOCでフェッチ処理を行いながら表示するコンポーネント、Next.js側で利用
export const ContentWithFetch = () => withFetchArticle(Content)
※HOCについては公式や詳しく解説しているブログがあるのでそちらを参照してください。
CSR(Client Side Rendering)への対応
Next.jsとAstroではCSRの指定の仕方が異なります。
Next.jsではコンポーネントの先頭行に'use client'ディレクティブを付けることでCSR指定していますが、Astroでは.astroファイルでコンポーネントを呼び出す際にclient:*を指定する必要があります。
これはつまりNext.jsのTSX(JSX)コンポーネントを.astro側でそのまま配置しただけではCSRにならないということです。
ただ雑にclient指定するとCSRの範囲が広くなり静的HTMLにビルドされるJavascriptの量も増えてしまいます。なるべくCSRが使われる箇所に限定されるようにclinet指定を行う必要があります。
ここが厄介なポイントでNext.jsで実装していたTSX(JSX)コンポーネントの子コンポーネントでCSRを行っている場合、CSRを絞るためには親コンポーネント側を.astroに置き換える必要があります。
この辺はSEOとして突き詰めると.astroで記述していくのが正解なのですが、Next.jsと併用する関係上ソース管理が二重になる問題があるのである程度のところで線引きをし親側も含めてclient指定するケースを許容しました。
(いずれAstroに完全移管する場合は解消できるかと思います)
他にも細かい実装はあるのですが、ざっくり上記の内容でNext.jsで表示していたページをAstro側で静的HTMLとして出力できるようになります。
まとめ
まさに小田原城を攻略する一夜城のようにAstroプロジェクトをNext.jsの横に数日で立ち上げることができました、元々のNext.jsに依存しないコンポーネント設計としていたのが有効に働いた感じです。表題ではNext.jsでSEOと戦うと書いていましたが結局戦っていたのはAstroでしたね。
結果はというとNext.jsのSSGと比べると5~10倍ほどファイルサイズが小さくなりかなりSEOに期待ができる状態です。
今は検証段階で数ページだけ対象ですが、SEOの効果が現れれば記事系ページはすべてAstroでSSGしていこうかと思います。(記事更新時のGithub ActionsでのISR的な実装や記事削除への対応など細かい点は残っていますがそれも追々実装していく予定です。)