Pinia をフレームワークレスで SSR:Nitro/Express 直結の同形レンダリング

Vue.js で状態管理を行う際、Pinia は最も現代的な選択肢の一つです。しかし、SSR(サーバーサイドレンダリング)環境で Pinia を使おうとすると、フレームワークに依存した実装が多く、自由度の高いカスタマイズが難しいという課題に直面します。
本記事では、Nuxt.js のようなフレームワークを使わず、Nitro と Express を直接組み合わせることで、Pinia の同形レンダリング(Isomorphic Rendering)を実現する方法を解説します。フレームワークレスでの実装は学習コストこそありますが、アプリケーションの構造を深く理解でき、柔軟なカスタマイズが可能になります。初心者の方にも理解していただけるよう、基礎から丁寧に説明していきますね。
背景
Pinia とは
Pinia は Vue.js 公式の状態管理ライブラリで、Vuex の後継として位置づけられています。
Pinia の最大の特徴は、Composition API との親和性の高さです。従来の Vuex では mutations
や actions
といった概念を理解する必要がありましたが、Pinia では関数を定義するだけでシンプルに状態管理ができます。
typescript// Pinia ストアの例
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
}),
actions: {
increment() {
this.count++;
},
},
});
上記のコードは、カウンターの状態を管理する最もシンプルな Pinia ストアです。state
で状態を定義し、actions
で状態を変更するメソッドを定義します。
Pinia は Vue.js と密接に統合されており、Vue 3 の Reactivity System を活用することで、自動的に依存関係を追跡し、効率的な再レンダリングを実現しています。TypeScript のサポートも充実しており、型推論が効くため、開発体験も優れています。
Vue.js との関係性を図で表すと、以下のようになります。
mermaidflowchart TB
app["Vue.js アプリケーション"]
pinia["Pinia ストア"]
comp1["コンポーネント A"]
comp2["コンポーネント B"]
comp3["コンポーネント C"]
app -->|提供| pinia
pinia -->|状態共有| comp1
pinia -->|状態共有| comp2
pinia -->|状態共有| comp3
comp1 -->|状態更新| pinia
comp2 -->|状態更新| pinia
comp3 -->|状態更新| pinia
Pinia はアプリケーション全体で状態を共有し、各コンポーネントから状態の参照・更新が可能です。この仕組みにより、コンポーネント間でのデータ受け渡しが簡潔になります。
SSR(サーバーサイドレンダリング)の必要性
SSR とは、サーバー側で HTML を生成してクライアントに送信する技術です。
クライアントサイドレンダリング(CSR)と比較した場合、SSR には以下のようなメリットがあります。
# | 項目 | SSR | CSR |
---|---|---|---|
1 | 初期表示速度 | ★★★ 速い | ★☆☆ 遅い |
2 | SEO 対応 | ★★★ 優れている | ★☆☆ 不十分 |
3 | SNS シェア対応 | ★★★ OGP が正しく表示される | ★☆☆ 動的に設定できない |
4 | JavaScript 無効環境 | ★★★ 動作する | ★☆☆ 動作しない |
5 | サーバー負荷 | ★☆☆ 高い | ★★★ 低い |
SSR の最も大きなメリットは、初期表示速度と SEO 対応です。サーバー側で HTML を生成するため、ユーザーは JavaScript のダウンロードや実行を待つことなく、すぐにコンテンツを閲覧できます。
検索エンジンのクローラーも、完全にレンダリングされた HTML を受け取るため、正確にコンテンツをインデックスできます。Twitter や Facebook などの SNS でリンクをシェアする際も、OGP(Open Graph Protocol)タグが正しく認識され、適切なサムネイルやディスクリプションが表示されるでしょう。
一方で、SSR にはサーバー側での処理が必要になるため、サーバー負荷が高くなるというデメリットもあります。また、実装の複雑さも増します。
Pinia を SSR で使う意義は、サーバーサイドでデータをプリフェッチし、初期状態としてクライアントに渡すことで、ユーザー体験を大幅に向上させられる点にあります。例えば、ユーザー情報や記事一覧などのデータをサーバー側で取得しておけば、クライアント側では追加の API リクエストなしにすぐに表示できますね。
フレームワークレスアプローチとは
Nuxt.js は Vue.js の SSR を簡単に実装できる優れたフレームワークです。しかし、フレームワークには以下のような制約もあります。
- 決められたディレクトリ構造に従う必要がある
- フレームワーク独自の API を学習する必要がある
- カスタマイズの自由度が限られる
- フレームワークのバージョンアップに追従する必要がある
フレームワークレスアプローチとは、これらの制約から解放され、必要な機能だけを組み合わせて自分でアプリケーションを構築する手法です。
本記事で扱う Nitro と Express は、それぞれ以下の役割を担います。
mermaidflowchart LR
client["クライアント<br/>ブラウザ"]
express["Express<br/>HTTP サーバー"]
nitro["Nitro<br/>SSR エンジン"]
vue["Vue.js<br/>レンダリング"]
pinia["Pinia<br/>状態管理"]
client -->|HTTP リクエスト| express
express -->|レンダリング依頼| nitro
nitro -->|Vue アプリ実行| vue
vue -->|状態取得| pinia
pinia -->|状態返却| vue
vue -->|HTML 生成| nitro
nitro -->|HTML 返却| express
express -->|HTTP レスポンス| client
Express は Node.js の代表的な Web フレームワークで、HTTP サーバーとしてリクエストを受け取り、レスポンスを返す役割を担います。ルーティングやミドルウェアの仕組みが豊富で、認証やロギングなど、Web アプリケーションに必要な機能を柔軟に追加できます。
Nitro は UnJS プロジェクトの一部で、軽量かつ高速な SSR エンジンです。Nuxt 3 の内部でも使われており、Vue.js アプリケーションをサーバーサイドでレンダリングする機能を提供します。ビルド時の最適化も優れており、本番環境でのパフォーマンスが高いのが特徴です。
この 2 つを組み合わせることで、Express の豊富なエコシステムと Nitro の高性能な SSR を両立できます。フレームワークに縛られず、自分で設計した構造で実装できるため、要件に合わせた柔軟なカスタマイズが可能になるでしょう。
課題
Pinia を SSR で使う際の一般的な問題
Pinia を SSR 環境で使う際には、いくつかの技術的な課題があります。
状態の共有問題
サーバーサイドでは、複数のリクエストが同時に処理されます。もし Pinia ストアをシングルトン(アプリケーション全体で 1 つのインスタンス)として扱うと、異なるユーザーのリクエスト間で状態が共有されてしまいます。
typescript// 問題のあるコード例
import { createPinia } from 'pinia';
// グローバルに1つだけ作成してしまう(危険)
const pinia = createPinia();
export default pinia;
このコードでは、すべてのリクエストで同じ pinia
インスタンスが使われるため、ユーザー A のリクエストで設定した状態が、ユーザー B のレスポンスに含まれてしまう可能性があります。これは重大なセキュリティ問題です。
ハイドレーションエラー
SSR では、サーバー側で生成した HTML と、クライアント側で Vue アプリが初期化した際の DOM が一致している必要があります。この一致確認のプロセスを「ハイドレーション」と呼びます。
もしサーバーとクライアントで Pinia の状態が異なると、ハイドレーションエラーが発生し、以下のような警告がコンソールに出力されます。
sqlHydration completed but contains mismatches.
このエラーが発生すると、Vue はクライアント側で DOM を再構築するため、SSR のパフォーマンス上のメリットが失われてしまいます。
メモリリーク
サーバーサイドでは、レンダリング後に不要になったオブジェクトを適切に破棄しないと、メモリリークが発生します。Pinia ストアやそれに紐づく Vue アプリインスタンスが残り続けると、サーバーのメモリ使用量が増加し、最終的にはクラッシュする可能性もあります。
長時間稼働するサーバーでは、この問題が顕著に現れますので、適切なライフサイクル管理が不可欠です。
フレームワークレスでの固有の課題
フレームワークを使わない実装では、さらに以下の課題に対処する必要があります。
サーバーとクライアントの状態同期
Nuxt.js などのフレームワークでは、サーバーで初期化した Pinia の状態を自動的にクライアントに引き継ぐ仕組みが用意されています。しかし、フレームワークレスでは、この仕組みを自分で実装しなければなりません。
具体的には、以下のプロセスが必要です。
mermaidsequenceDiagram
participant Server as サーバー
participant Pinia as Pinia ストア
participant HTML as HTML
participant Client as クライアント
Server->>Pinia: ストア初期化
Server->>Pinia: データフェッチ
Pinia-->>Server: 状態を返却
Server->>HTML: 状態をシリアライズして埋め込み
HTML-->>Client: HTML 送信
Client->>HTML: script タグから状態を取得
Client->>Pinia: 状態で初期化
Pinia-->>Client: ハイドレーション完了
この図のように、サーバー側で作成した状態を JSON 形式でシリアライズし、HTML に埋め込み、クライアント側でデシリアライズして Pinia ストアを復元する必要があります。
ストアのインスタンス管理
前述の状態共有問題を避けるため、リクエストごとに新しい Pinia インスタンスを作成する必要があります。しかし、Express のミドルウェアチェーンの中で、どのタイミングでインスタンスを作成し、どこで破棄するかを適切に設計しなければなりません。
適切なスコープ管理を行わないと、メモリリークや状態の混在が発生してしまいます。
SSR コンテキストの受け渡し
サーバーサイドでレンダリングする際、リクエストの情報(URL、ヘッダー、Cookie など)をコンポーネントやストアに渡す必要があります。フレームワークでは useContext
のような API が用意されていますが、フレームワークレスでは独自に実装する必要があるでしょう。
コンテキストの受け渡しが適切でないと、認証情報が取得できなかったり、ルーティング情報が不正確になったりする問題が発生します。
解決策
Nitro を使った SSR 環境構築
Nitro は軽量で高速な SSR エンジンとして、サーバーサイドレンダリングの基盤を提供します。
Nitro のセットアップ
まず、Nitro をプロジェクトに導入しましょう。Nitro は設定ファイルベースで動作します。
typescript// nitro.config.ts
import { defineNitroConfig } from 'nitropack/config';
export default defineNitroConfig({
srcDir: 'server',
output: {
dir: '.output',
publicDir: '.output/public',
},
});
この設定ファイルでは、ソースコードのディレクトリと出力先を指定しています。srcDir
にはサーバーサイドのコードを配置し、output
にはビルド後のファイルが生成されます。
ルーティング設定
Nitro では、ファイルベースのルーティングが可能です。server/routes
ディレクトリ以下にファイルを配置することで、自動的にルートが生成されます。
typescript// server/routes/index.ts
export default defineEventHandler((event) => {
return {
message: 'Hello from Nitro',
};
});
このコードは、ルートパス /
へのリクエストに対して JSON レスポンスを返します。defineEventHandler
は Nitro が提供するヘルパー関数で、リクエストハンドラーを定義します。
SSR のためのレンダリングハンドラーは、後ほど詳しく実装していきますね。
Express との直結方法
Express サーバーを構築し、Nitro と統合する方法を見ていきましょう。
Express サーバーの構築
Express の基本的なセットアップは以下のようになります。
typescript// server.ts
import express from 'express';
const app = express();
const port = 3000;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
この部分では、Express アプリケーションのインスタンスを作成し、JSON とフォームデータのパース機能を有効にしています。
Nitro との統合パターン
Nitro は Express のミドルウェアとして統合できます。Nitro のビルド出力を Express で利用する形です。
typescript// server.ts(続き)
import { toNodeListener } from 'h3';
import { createApp } from './nitro-app';
const nitroApp = createApp();
// Nitro を Express のミドルウェアとして使用
app.use(toNodeListener(nitroApp.handler));
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
toNodeListener
は h3(Nitro の内部で使われる HTTP フレームワーク)のハンドラーを Node.js の標準的なリクエストリスナーに変換する関数です。これにより、Nitro のハンドラーを Express のミドルウェアとして使用できます。
この統合により、Express の豊富なミドルウェアエコシステムと Nitro の高速な SSR を組み合わせられるようになりました。
Pinia ストアの同形レンダリング実装
ここからが本記事の核心部分です。Pinia を SSR 環境で動作させるための実装を見ていきましょう。
サーバー側でのストア初期化
サーバーサイドでは、リクエストごとに新しい Pinia インスタンスを作成する必要があります。
typescript// server/utils/create-app.ts
import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import App from '../../src/App.vue';
export function createApp() {
// Vue アプリとストアをリクエストごとに作成
const app = createSSRApp(App);
const pinia = createPinia();
app.use(pinia);
return { app, pinia };
}
createSSRApp
は SSR 用の Vue アプリケーションを作成する関数です。通常の createApp
との違いは、ハイドレーションに対応している点です。
この関数を呼び出すたびに、新しい Vue アプリと Pinia インスタンスが生成されるため、リクエスト間で状態が共有されることはありません。
クライアント側でのストア復元
クライアント側では、サーバーから受け取った初期状態を使って Pinia ストアを復元します。
typescript// src/entry-client.ts
import { createApp } from './main';
const { app, pinia } = createApp();
// サーバーから渡された初期状態を復元
if (window.__PINIA_STATE__) {
pinia.state.value = window.__PINIA_STATE__;
}
app.mount('#app');
window.__PINIA_STATE__
には、サーバー側でシリアライズされた状態が格納されています。この状態を pinia.state.value
に代入することで、サーバーとクライアントで同じ状態を共有できます。
状態のシリアライズ・デシリアライズ
サーバー側で状態を HTML に埋め込む処理を実装します。
typescript// server/routes/render.ts
import { renderToString } from 'vue/server-renderer';
import { createApp } from '../utils/create-app';
export default defineEventHandler(async (event) => {
const { app, pinia } = createApp();
// Vue アプリをレンダリング
const html = await renderToString(app);
// Pinia の状態をシリアライズ
const state = JSON.stringify(pinia.state.value);
return `
<!DOCTYPE html>
<html>
<head>
<title>SSR App</title>
</head>
<body>
<div id="app">${html}</div>
<script>
window.__PINIA_STATE__ = ${state}
</script>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`;
});
renderToString
は Vue アプリを HTML 文字列に変換する関数です。レンダリング後、pinia.state.value
を JSON 形式でシリアライズし、<script>
タグ内に埋め込みます。
この仕組みにより、サーバーとクライアントで同じ状態が共有され、ハイドレーションエラーを防げます。
状態管理の最適化
より堅牢な実装にするため、状態管理の最適化を行いましょう。
ストアのスコープ管理
Express のミドルウェアを使って、リクエストごとにコンテキストを管理します。
typescript// server/middleware/context.ts
import type {
Request,
Response,
NextFunction,
} from 'express';
export function contextMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// リクエストごとに一意の ID を生成
req.context = {
requestId: Math.random().toString(36).substring(7),
startTime: Date.now(),
};
next();
}
このミドルウェアは、各リクエストにコンテキスト情報を追加します。これにより、ログやデバッグ時にリクエストを追跡しやすくなります。
リクエストごとの状態分離
AsyncLocalStorage を使うと、より安全にリクエストごとの状態を分離できます。
typescript// server/utils/async-context.ts
import { AsyncLocalStorage } from 'async_hooks';
interface Context {
pinia: any;
app: any;
}
export const asyncContext =
new AsyncLocalStorage<Context>();
export function runWithContext<T>(
context: Context,
fn: () => T
): T {
return asyncContext.run(context, fn);
}
export function getContext(): Context | undefined {
return asyncContext.getStore();
}
AsyncLocalStorage
は Node.js の機能で、非同期処理の連鎖の中でコンテキストを保持できます。これを使うことで、グローバル変数を使わずに、安全にリクエスト固有の情報を保持できるでしょう。
具体例
プロジェクトセットアップ
実際に動作するプロジェクトを作成していきましょう。
必要なパッケージのインストール
まず、必要な依存パッケージをインストールします。
bashyarn add vue pinia express h3 nitropack
yarn add -D @types/express @types/node typescript vite
これらのパッケージにより、Vue.js、Pinia、Express、Nitro の環境が整います。TypeScript と Vite は開発時のビルドツールとして使用します。
ディレクトリ構成
プロジェクトのディレクトリ構成は以下のようになります。
bashproject-root/
├── src/ # クライアントサイドのコード
│ ├── App.vue # ルートコンポーネント
│ ├── entry-client.ts # クライアントエントリー
│ ├── main.ts # アプリ作成関数
│ └── stores/ # Pinia ストア
│ └── counter.ts
├── server/ # サーバーサイドのコード
│ ├── routes/ # Nitro ルート
│ │ └── render.ts
│ ├── utils/ # ユーティリティ
│ │ └── create-app.ts
│ └── middleware/ # ミドルウェア
├── server.ts # Express サーバー
├── nitro.config.ts # Nitro 設定
├── tsconfig.json # TypeScript 設定
└── package.json
この構成により、クライアントとサーバーのコードが明確に分離され、保守性が高くなります。
Nitro サーバーの実装
Nitro のレンダリングハンドラーを実装しましょう。
エントリーポイント作成
typescript// server/utils/create-app.ts
import { createSSRApp } from 'vue';
import { createPinia } from 'pinia';
import { createRouter } from '../router';
import App from '../../src/App.vue';
export function createApp() {
const app = createSSRApp(App);
const pinia = createPinia();
const router = createRouter();
app.use(pinia);
app.use(router);
return { app, pinia, router };
}
この関数は、SSR 用の Vue アプリ、Pinia ストア、Vue Router を作成します。リクエストごとに呼び出されることで、状態の分離が保証されます。
レンダリングハンドラー
typescript// server/routes/[...path].ts
import { renderToString } from 'vue/server-renderer';
import { createApp } from '../utils/create-app';
export default defineEventHandler(async (event) => {
const url = event.node.req.url || '/';
const { app, pinia, router } = createApp();
// ルーターを初期化
await router.push(url);
await router.isReady();
// Vue アプリをレンダリング
const appHtml = await renderToString(app);
// 状態をシリアライズ
const state = JSON.stringify(pinia.state.value);
return `
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSR with Pinia</title>
</head>
<body>
<div id="app">${appHtml}</div>
<script>window.__PINIA_STATE__ = ${state}</script>
<script type="module" src="/src/entry-client.ts"></script>
</body>
</html>
`;
});
[...path].ts
というファイル名により、すべてのパスに対してこのハンドラーが実行されます。リクエストされた URL に応じてルーターを初期化し、適切なコンポーネントをレンダリングします。
Express サーバーの実装
Express サーバーを構築し、Nitro と統合します。
ミドルウェア設定
typescript// server.ts
import express from 'express';
import { createServer as createViteServer } from 'vite';
async function createServer() {
const app = express();
// Vite の開発サーバーをミドルウェアとして使用
const vite = await createViteServer({
server: { middlewareMode: true },
appType: 'custom',
});
app.use(vite.middlewares);
return app;
}
createServer().then((app) => {
app.listen(3000, () => {
console.log('Server running at http://localhost:3000');
});
});
開発環境では Vite の開発サーバーを Express のミドルウェアとして使用します。これにより、ホットモジュールリプレースメント(HMR)が有効になり、開発体験が向上します。
Nitro との連携
typescript// server.ts(本番環境用)
import express from 'express';
import { toNodeListener } from 'h3';
async function createProductionServer() {
const app = express();
// 静的ファイルの配信
app.use(express.static('.output/public'));
// Nitro ハンドラーをインポート
const { handler } = await import(
'./.output/server/index.mjs'
);
// Nitro ハンドラーを Express に統合
app.use(toNodeListener(handler));
return app;
}
createProductionServer().then((app) => {
app.listen(3000, () => {
console.log(
'Production server running at http://localhost:3000'
);
});
});
本番環境では、Nitro でビルドした出力を Express で配信します。.output/public
ディレクトリには静的ファイル(CSS、JavaScript など)が含まれており、これらを Express の静的ファイル配信機能で提供します。
Pinia ストアの実装
実際に使用する Pinia ストアを作成しましょう。
ストア定義
typescript// src/stores/counter.ts
import { defineStore } from 'pinia';
export const useCounterStore = defineStore('counter', {
state: () => ({
count: 0,
lastUpdated: null as Date | null,
}),
getters: {
doubleCount: (state) => state.count * 2,
},
actions: {
increment() {
this.count++;
this.lastUpdated = new Date();
},
decrement() {
this.count--;
this.lastUpdated = new Date();
},
setCount(value: number) {
this.count = value;
this.lastUpdated = new Date();
},
},
});
このストアは、カウンターの値と最終更新日時を管理します。getters
では計算されたプロパティを定義し、actions
では状態を変更するメソッドを定義しています。
SSR 対応の初期化処理
サーバーサイドでデータをプリフェッチする処理を追加します。
typescript// src/stores/user.ts
import { defineStore } from 'pinia';
export const useUserStore = defineStore('user', {
state: () => ({
user: null as User | null,
loading: false,
}),
actions: {
async fetchUser(userId: string) {
this.loading = true;
try {
// API からユーザー情報を取得
const response = await fetch(
`/api/users/${userId}`
);
this.user = await response.json();
} catch (error) {
console.error('Failed to fetch user:', error);
} finally {
this.loading = false;
}
},
},
});
interface User {
id: string;
name: string;
email: string;
}
このストアは非同期でユーザー情報を取得します。サーバーサイドでこの fetchUser
を呼び出せば、初期状態としてユーザー情報がクライアントに渡されます。
サーバーサイドでのプリフェッチは以下のように実装します。
typescript// server/routes/[...path].ts(修正版)
export default defineEventHandler(async (event) => {
const url = event.node.req.url || '/';
const { app, pinia, router } = createApp();
await router.push(url);
await router.isReady();
// ルートコンポーネントで必要なデータをプリフェッチ
const matchedComponents =
router.currentRoute.value.matched;
await Promise.all(
matchedComponents.map(async (route) => {
if (route.meta.fetchData) {
await route.meta.fetchData(pinia);
}
})
);
const appHtml = await renderToString(app);
const state = JSON.stringify(pinia.state.value);
// ... HTML を返す
});
ルートのメタ情報に fetchData
関数を定義しておき、レンダリング前に実行することで、サーバー側でデータを取得できます。
Vue コンポーネントでの利用
Pinia ストアを Vue コンポーネントで使用する方法を見ていきましょう。
サーバーサイドでの使用
vue<!-- src/components/Counter.vue -->
<template>
<div class="counter">
<h2>カウンター</h2>
<p>現在の値: {{ count }}</p>
<p>2倍の値: {{ doubleCount }}</p>
<p>最終更新: {{ formattedDate }}</p>
<button @click="increment">+1</button>
<button @click="decrement">-1</button>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useCounterStore } from '../stores/counter';
const store = useCounterStore();
// ストアの値を直接参照
const count = computed(() => store.count);
const doubleCount = computed(() => store.doubleCount);
// 日付のフォーマット
const formattedDate = computed(() => {
if (!store.lastUpdated) return '未更新';
return store.lastUpdated.toLocaleString('ja-JP');
});
// アクションを呼び出し
const increment = () => store.increment();
const decrement = () => store.decrement();
</script>
コンポーネントでは useCounterStore
を呼び出すだけで、ストアにアクセスできます。サーバーサイドでもクライアントサイドでも、同じコードが動作します。
クライアントサイドでの使用
クライアントサイドでは、ハイドレーション後に通常通り Vue の機能が使えます。
typescript// src/entry-client.ts
import { createApp } from './main';
const { app, pinia } = createApp();
// サーバーから渡された状態を復元
if (window.__PINIA_STATE__) {
pinia.state.value = window.__PINIA_STATE__;
}
// DOM にマウント
app.mount('#app');
// マウント後、クライアント側でのみ実行したい処理
app.config.globalProperties.$router.isReady().then(() => {
console.log('アプリケーションが準備できました');
});
ハイドレーション後、ユーザーの操作に応じて状態が更新され、リアクティブに UI が変更されます。
動作確認
実装したアプリケーションを動作させてみましょう。
開発サーバー起動
bash# 開発モードで起動
yarn dev
開発サーバーが起動したら、ブラウザで http://localhost:3000
にアクセスします。
SSR の確認方法
SSR が正しく動作しているかを確認するには、以下の方法があります。
- ページソースの確認
ブラウザで「ページのソースを表示」を選択し、HTML を確認します。<div id="app">
の中に、Vue コンポーネントのレンダリング結果が含まれていれば、SSR が動作しています。
html<div id="app">
<div class="counter">
<h2>カウンター</h2>
<p>現在の値: 0</p>
<p>2倍の値: 0</p>
<!-- ... -->
</div>
</div>
- JavaScript を無効化して確認
ブラウザの開発者ツールで JavaScript を無効化し、ページをリロードします。コンテンツが表示されれば、サーバーサイドで HTML が生成されている証拠です。
- PINIA_STATE の確認
開発者ツールのコンソールで window.__PINIA_STATE__
を確認します。Pinia の状態が正しくシリアライズされていれば、オブジェクトが表示されます。
javascriptconsole.log(window.__PINIA_STATE__);
// { counter: { count: 0, lastUpdated: null } }
- ネットワークタブの確認
開発者ツールのネットワークタブで、初回リクエストのレスポンスを確認します。HTML に Pinia の状態が埋め込まれていることを確認できます。
これらの確認方法により、SSR とハイドレーションが正しく動作していることを検証できるでしょう。
まとめ
本記事では、Pinia をフレームワークレスで SSR 環境で使用する方法を、Nitro と Express の組み合わせで実装しました。
実装のポイント振り返り
重要なポイントをまとめると、以下の 3 点になります。
-
リクエストごとの状態分離:
createApp
関数を毎回呼び出し、新しい Pinia インスタンスを作成することで、リクエスト間での状態共有を防ぎます -
状態のシリアライズとデシリアライズ: サーバー側で
JSON.stringify
で状態をシリアライズし、HTML に埋め込み、クライアント側で復元することで、ハイドレーションエラーを防ぎます -
Express と Nitro の連携: Express のミドルウェアエコシステムと Nitro の高速な SSR を組み合わせることで、柔軟かつ高性能なアプリケーションを構築できます
応用可能なシーン
この実装パターンは、以下のようなシーンで活用できます。
- 既存の Express アプリに Vue.js の SSR を段階的に導入したい場合
- Nuxt.js では実現できない独自のカスタマイズが必要な場合
- マイクロサービスアーキテクチャで、特定のサービスだけ SSR を導入したい場合
- 学習目的で SSR の仕組みを深く理解したい場合
フレームワークレスでの実装は、最初は複雑に感じるかもしれませんが、SSR の仕組みを深く理解できるため、トラブルシューティングや最適化にも強くなれますね。
今後の展望
本記事で紹介した基本的な実装をベースに、以下のような機能を追加していくことができます。
- エラーハンドリングとエラーページのレンダリング
- キャッシュ戦略の導入によるパフォーマンス向上
- 認証・認可の実装
- 多言語対応(i18n)
- メタタグの動的生成(SEO 対策)
- プリフェッチとコード分割による最適化
Pinia の SSR は奥が深いですが、一歩ずつ理解を深めていけば、必ず使いこなせるようになります。ぜひ本記事を参考に、実際に手を動かして試してみてください。
関連リンク
- article
Pinia をフレームワークレスで SSR:Nitro/Express 直結の同形レンダリング
- article
Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解
- article
Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴
- article
Pinia アーキテクチャ超図解:リアクティビティとストアの舞台裏を一枚で理解
- article
Pinia × TypeScript:型安全なストア設計入門
- article
Pinia の基本 API 解説:defineStore・state・getters・actions
- article
GitHub Copilot を macOS で最短導入:VS Code・Neovim・JetBrains の横断設定
- article
Vue.js を macOS + yarn で最短セットアップ:ESLint/Prettier/TS/パスエイリアス
- article
Tailwind CSS を macOS で最短導入:Yarn PnP・PostCSS・ESLint 連携レシピ
- article
GitHub Actions を macOS ランナーで使いこなす:Xcode/コード署名/キーチェーン設定
- article
Svelte を macOS + yarn + TypeScript で最短構築:ESLint/Prettier まで一気通貫
- article
Git の部分取得を徹底比較:sparse-checkout/partial clone/shallow の違いと使い分け
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来