T-CREATOR

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

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 では mutationsactions といった概念を理解する必要がありましたが、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 には以下のようなメリットがあります。

#項目SSRCSR
1初期表示速度★★★ 速い★☆☆ 遅い
2SEO 対応★★★ 優れている★☆☆ 不十分
3SNS シェア対応★★★ OGP が正しく表示される★☆☆ 動的に設定できない
4JavaScript 無効環境★★★ 動作する★☆☆ 動作しない
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 が正しく動作しているかを確認するには、以下の方法があります。

  1. ページソースの確認

ブラウザで「ページのソースを表示」を選択し、HTML を確認します。<div id="app"> の中に、Vue コンポーネントのレンダリング結果が含まれていれば、SSR が動作しています。

html<div id="app">
  <div class="counter">
    <h2>カウンター</h2>
    <p>現在の値: 0</p>
    <p>2倍の値: 0</p>
    <!-- ... -->
  </div>
</div>
  1. JavaScript を無効化して確認

ブラウザの開発者ツールで JavaScript を無効化し、ページをリロードします。コンテンツが表示されれば、サーバーサイドで HTML が生成されている証拠です。

  1. PINIA_STATE の確認

開発者ツールのコンソールで window.__PINIA_STATE__ を確認します。Pinia の状態が正しくシリアライズされていれば、オブジェクトが表示されます。

javascriptconsole.log(window.__PINIA_STATE__);
// { counter: { count: 0, lastUpdated: null } }
  1. ネットワークタブの確認

開発者ツールのネットワークタブで、初回リクエストのレスポンスを確認します。HTML に Pinia の状態が埋め込まれていることを確認できます。

これらの確認方法により、SSR とハイドレーションが正しく動作していることを検証できるでしょう。

まとめ

本記事では、Pinia をフレームワークレスで SSR 環境で使用する方法を、Nitro と Express の組み合わせで実装しました。

実装のポイント振り返り

重要なポイントをまとめると、以下の 3 点になります。

  1. リクエストごとの状態分離: createApp 関数を毎回呼び出し、新しい Pinia インスタンスを作成することで、リクエスト間での状態共有を防ぎます

  2. 状態のシリアライズとデシリアライズ: サーバー側で JSON.stringify で状態をシリアライズし、HTML に埋め込み、クライアント側で復元することで、ハイドレーションエラーを防ぎます

  3. Express と Nitro の連携: Express のミドルウェアエコシステムと Nitro の高速な SSR を組み合わせることで、柔軟かつ高性能なアプリケーションを構築できます

応用可能なシーン

この実装パターンは、以下のようなシーンで活用できます。

  • 既存の Express アプリに Vue.js の SSR を段階的に導入したい場合
  • Nuxt.js では実現できない独自のカスタマイズが必要な場合
  • マイクロサービスアーキテクチャで、特定のサービスだけ SSR を導入したい場合
  • 学習目的で SSR の仕組みを深く理解したい場合

フレームワークレスでの実装は、最初は複雑に感じるかもしれませんが、SSR の仕組みを深く理解できるため、トラブルシューティングや最適化にも強くなれますね。

今後の展望

本記事で紹介した基本的な実装をベースに、以下のような機能を追加していくことができます。

  • エラーハンドリングとエラーページのレンダリング
  • キャッシュ戦略の導入によるパフォーマンス向上
  • 認証・認可の実装
  • 多言語対応(i18n)
  • メタタグの動的生成(SEO 対策)
  • プリフェッチとコード分割による最適化

Pinia の SSR は奥が深いですが、一歩ずつ理解を深めていけば、必ず使いこなせるようになります。ぜひ本記事を参考に、実際に手を動かして試してみてください。

関連リンク