こんにちは!フロントエンド開発の現場で、日々モダンなツールと格闘お疲れ様です。先輩エンジニアの私です。
今回は、現代のフロントエンド開発において「爆速のビルドツール」として覇権を握る Vite(ヴィート)、そしてその中でも多くの開発者が一度はハマる鬼門、「SSR(サーバーサイドレンダリング)とハイドレーション不一致」 について徹底的に解説します。
「ビルドは通ったのに、ブラウザをリロードした瞬間にコンソールが真っ赤なエラーで染まる……」
「サーバーで作ったHTMLと、クライアント(ブラウザ)で最初に描画されたDOMが違うと言われる……」
そんな悪夢のような現象に直面したことはありませんか?これをマスターすれば、ViteのSSR構築における不安が消え去り、毎日のコーディングが劇的に楽になりますよ。
—
1. なぜViteのSSRで「ハイドレーション不一致」が起きるのか?
そもそも、Viteはなぜこれほど速いのでしょうか?それは、開発時にはネイティブESM(ES Modules)をブラウザに直接配信し、本番ビルドにはRollupを裏側で回すという、極めて合理的なアーキテクチャを採用しているからです。
しかし、SSR(サーバーサイドレンダリング)を導入した途端、話は複雑になります。
ハイドレーションのメカニズムと落とし穴
SSRでは、以下のようなライフサイクルを辿ります。
1. サーバー側(Node.js等): リクエストを受けると、VueやReactのコンポーネントを実行して「HTML文字列」を生成し、ブラウザへ返します。
2. クライアント側(ブラウザ): サーバーから送られてきた静的なHTMLを表示した後、JavaScriptを読み込み、同じコンポーネントをもう一度実行してイベントリスナーなどをDOMに「接着(ハイドレーション)」します。
このとき、「サーバーで作ったHTML」と「クライアントが最初に作ったHTML」の構造やテキストが1バイトでも異なると、ReactやVueはパニックを起こします。 これがハイドレーションエラーです。
特にVite環境では、ビルド時の最適化や環境変数の扱い方の違いから、この差分が非常に発生しやすいのです。
—
2. 最小構成で学ぶ!Vite SSRの基礎セットアップ
理屈を語るよりも、実際に手を動かすのが一番の近道です。ここでは、最もトラブルが起きやすい「環境変数の切り替え」と「SSRエントリポイント」を含む最小限の構成を作ってみましょう。
プロジェクト構成
vite-ssr-hands-on/
├── server.js # Node.jsによるSSRサーバー
├── index.html # クライアントのエントリテンプレート
├── package.json # 依存関係定義
└── src/
├── App.vue # 共有コンポーネント(またはReactなど)
├── entry-client.js# クライアントサイドのハイドレーション用
└── entry-server.js# サーバーサイドのレンダリング用
1. `package.json` の設定
まずはパッケージの定義です。ViteとVueを例に進めます。
{
“name”: “vite-ssr-hands-on”,
“private”: true,
“version”: “1.0.0”,
“type”: “module”,
“scripts”: {
“dev”: “node server.js”,
“build:client”: “vite build –ssrManifest –outDir dist/client”,
“build:server”: “vite build –ssr src/entry-server.js –outDir dist/server”
},
“dependencies”: {
“express”: “^4.19.2”,
“vue”: “^3.4.21”
},
“devDependencies”: {
“@vitejs/plugin-vue”: “^5.0.4”,
“vite”: “^5.1.6”
}
}
2. クライアントとサーバーのエントリポイント
ここが最初の重要ポイントです。「今動いているのはサーバーか、ブラウザか」を意識したコードを書く必要があります。
`src/entry-client.js` (ブラウザで実行される)
import { createApp } from ‘./App.js’
// Vueアプリケーションのインスタンスを生成
const { app } = createApp()
// サーバー側でレンダリングされた静的HTMLに対して、イベント等を結びつける(ハイドレーション)
app.mount(‘#app’)
console.log(‘[Client] ハイドレーションが完了しました’)
`src/entry-server.js` (サーバーで実行される)
import { renderToString } from ‘vue/server-renderer’
import { createApp } from ‘./App.js’
export async function render(url) {
const { app } = createApp()
// サーバー側でHTML文字列へと変換する
const ctx = {}
const html = await renderToString(app, ctx)
return { html }
}
—
3. 現場で一番多い原因:環境変数と「現在時刻・乱数」の罠
ハイドレーションエラーを引き起こす王様の原因は、「サーバーとクライアントで値がズレるコードをコンポーネント内に書いてしまうこと」です。
❌ やってはいけないアンチパターン
現在時刻: {{ new Date().toLocaleTimeString() }}
APIエンドポイント: {{ process.env.VITE_API_URL }}
このコードを実行すると、ブラウザのコンソールに次のような冷酷なエラーが出現します。
> Hydration failed because the initial UI does not match what was rendered on the server.
🛠 対策:クライアント専用のライフサイクルで処理する
サーバーとクライアントで結果が変わる動的な値(現在時刻、乱数、ブラウザ専用の `window` オブジェクトへの依存など)は、「マウントされた後(クライアント側)」にのみ実行するように制御するのが鉄則です。
現在時刻: {{ timeString || ‘読み込み中…’ }}
このように、「サーバーとクライアントで初期レンダリングのDOMツリーが完全に一致する」状態を担保することが、Vite SSR成功の絶対条件です。
—
4. 確実に原因を特定するデバッグ手法
もしエラーが出てしまったら、どうやって原因箇所を特定すればよいでしょうか?ベテランが使っている実践的なデバッグ手法を伝授します。
ステップ1: 開発サーバーでSSRの挙動を模倣する
Viteの開発サーバー(`vite dev`)は、デフォルトでSSRのミドルウェアとして動作します。コンソールに出るエラーログのスタックトレースを注意深く読みましょう。Viteはソースマップを完璧に維持しているため、どのコンポーネントの何行目で不一致が起きたのかを正確に示してくれます。
ステップ2: プロキシや条件分岐で「SSRフラグ」を視覚化する
サーバーサイド(Node.js)とクライアントサイドでデータの取得方法や挙動を変えたい場合、Viteが提供する環境変数を活用します。
// Viteは import.meta.env.SSR という強力なブール値を提供しています
if (import.meta.env.SSR) {
console.log(‘いま、サーバーサイドで実行されています’)
} else {
console.log(‘いま、ブラウザ(クライアント)で実行されています’)
}
この `import.meta.env.SSR` を利用して、データ取得のロジックをプロキシやAPIクライアント層で切り替えます。
// src/api.js
export async function fetchData() {
if (import.meta.env.SSR) {
// サーバーサイド時は、外部APIへ直接内部ネットワーク経由で叩く
return await internalApiCall()
} else {
// クライアントサイド時は、ブラウザからの通常のfetch
const res = await fetch(‘/api/data’)
return await res.json()
}
}
このように環境を明示的に分離することで、「サーバーでは取れたのにブラウザではない」といった非同期データの食い違いによるハイドレーションエラーを根絶できます。
—
5. 動作確認:自分の手でSSRを起動してみよう
それでは、簡易的なNode.jsサーバー(`server.js`)を組み上げて、実際にブラウザでSSRの動作を確認してみましょう。
`server.js`
import fs from ‘fs’
import path from ‘path’
import { fileURLToPath } from ‘url’
import express from ‘express’
const __dirname = path.dirname(fileURLToPath(import.meta.url))
async function createServer() {
const app = express()
// Viteを開発サーバーモードで作成
const vite = await import(‘vite’).then((v) =>
v.createServer({
server: { middlewareMode: true },
appType: ‘custom’
})
)
// ViteのミドルウェアをConnectインスタンスとして使う
app.use(vite.middlewares)
app.use(”, async (req, res) => {
try {
const url = req.originalUrl
// 1. index.html を読み込む
let template = fs.readFileSync(
path.resolve(__dirname, ‘index.html’),
‘utf-8’
)
// 2. ViteのHTMLパース・インジェクションを通す(HMRなどのため)
template = await vite.transformIndexHtml(url, template)
// 3. サーバーエントリから render 関数をSSR用としてロードする
const { render } = await vite.ssrLoadModule(‘/src/entry-server.js’)
// 4. HTMLをレンダリング
const appHtml = await render(url)
// 5. テンプレートの を実際のHTMLに置換
const html = template.replace(``, appHtml.html)
// 6. レスポンスとして返却
res.status(200).set({ ‘Content-Type’: ‘text/html’ }).end(html)
} catch (e) {
vite.ssrFixStacktrace(e)
console.error(e)
res.status(500).end(e.message)
}
})
app.listen(3000, () => {
console.log(‘🚀 SSRサーバーが起動しました: http://localhost:3000’)
})
}
createServer()
これを起動するには、以下のコマンドを実行します。
依存パッケージのインストール後、サーバーを起動
node server.js
ブラウザで `http://localhost:3000` にアクセスし、ページのソースコード(右クリック → ページのソースを表示)を確認してみてください。JavaScriptが実行される前の段階で、すでにDOM構造がHTMLとしてきれいにレンダリングされていることが確認できるはずです。これがViteによる高速なSSRの世界です!
—
まとめ
今回はViteのSSR構築における最大の難所「ハイドレーション不一致」の原因と対策、そして実践的なデバッグ手法について解説しました。
- ハイドレーションエラーの正体 は、サーバーとクライアントの初回レンダリング結果のズレ。
- 現在時刻や乱数、`window` への依存 は必ず `onMounted` などのクライアント側ライフサイクルに隠す。
- `import.meta.env.SSR` を正しく使いこなし、環境ごとの挙動をコントロールする。
この原則さえ頭に入れておけば、Viteの爆速な開発フィールを損なうことなく、SEOに強くユーザー体験の優れたSSRアプリケーションを自在に構築できるようになります。
あなたの毎日のコーディングが、より快適でエキサイティングなものになりますように。次の現場でもぜひこの知見を活かしてください!