T-CREATOR

SolidJS の SSR(サーバーサイドレンダリング)を完全攻略

SolidJS の SSR(サーバーサイドレンダリング)を完全攻略

Web アプリケーションの世界で、ユーザー体験とパフォーマンスの向上は永遠のテーマです。特に初期表示速度と SEO 対応は、現代の Web 開発において避けて通れない重要な要素となっています。そんな中、SolidJS の SSR(サーバーサイドレンダリング)が、従来の課題を解決する革新的なアプローチとして注目を集めているのです。

今回は、SolidJS の SSR を基礎から実践まで段階的に学び、実際のプロジェクトで活用できるレベルまで完全攻略していきましょう。この記事を読み終える頃には、きっと SolidJS の SSR の魅力に心を奪われているはずですよ。

背景

SSR とは何か?なぜ重要なのか

SSR(Server-Side Rendering)とは、Web ページのレンダリング処理をサーバー側で行い、完全にレンダリングされた HTML をクライアントに送信する技術です。これにより、ユーザーは初期表示時により早くコンテンツを見ることができるようになります。

従来の CSR(Client-Side Rendering)では、以下のような流れでページが表示されます:

#処理時間ユーザー体験
1HTML ファイル取得100ms白い画面
2JavaScript ダウンロード500ms白い画面
3JavaScript パース・実行300ms白い画面
4レンダリング開始200msやっとコンテンツ表示

一方、SSR では:

#処理時間ユーザー体験
1サーバーでレンダリング200msサーバー処理中
2完成した HTML 取得100msすぐコンテンツ表示
3ハイドレーション300msインタラクティブ化

この差は、ユーザーにとって劇的な体験の向上をもたらします。特に、モバイルデバイスや通信環境が不安定な地域では、その効果は計り知れません。

SolidJS SSR の登場背景と特徴

SolidJS は、React に似た書き味でありながら、仮想 DOM を使わずに真のリアクティビティを実現するフレームワークです。その SSR 実装は、従来のフレームワークの課題を解決するために設計されました。

SolidJS SSR の主な特徴は以下の通りです:

細粒度リアクティビティによる最適化 SolidJS は変更された部分のみを更新するため、ハイドレーション時の処理が軽量です。

ゼロオーバーヘッドハイドレーション 仮想 DOM の差分計算が不要なため、ハイドレーション処理が高速です。

TypeScript 完全対応 型安全性を保ちながら、開発体験も向上させています。

課題

従来の SSR フレームワークの課題点

多くの開発者が経験する従来の SSR フレームワークの課題を見てみましょう。

React SSR の典型的な問題例

typescript// よくあるReact SSRのパフォーマンス問題
function HeavyComponent({ data }) {
  // この処理がサーバーとクライアントで二重実行される
  const processedData = useMemo(() => {
    return data.map((item) => expensiveCalculation(item));
  }, [data]);

  return (
    <div>
      {processedData.map((item) => (
        <ExpensiveChildComponent
          key={item.id}
          data={item}
        />
      ))}
    </div>
  );
}

このようなコンポーネントでは、サーバーサイドで一度計算した結果を、クライアントサイドで再度計算してしまうという無駄が発生します。

Next.js でよく遭遇するハイドレーションエラー

arduinoWarning: Text content did not match. Server: "2024-01-15" Client: "2024-01-16"
    at span
    at TimeDisplay
    at div
    at HomePage

これは、サーバーとクライアントで異なる結果が生成された際に発生する典型的なエラーです。

クライアントサイドレンダリングの限界

CSR オンリーのアプローチにも明確な限界があります:

SEO の問題 検索エンジンクローラーは、JavaScript の実行完了を待たずにページを評価することがあります。

初期表示速度の問題 大きな JavaScript バンドルをダウンロード・実行してからでないと、ユーザーがコンテンツを見ることができません。

Core Web Vitals への影響 LCP(Largest Contentful Paint)や CLS(Cumulative Layout Shift)などの指標に悪影響を与えがちです。

解決策

SolidJS SSR の仕組みと優位性

SolidJS の SSR は、これらの課題を根本的に解決するアプローチを採用しています。

真のリアクティビティによる効率化

typescript// SolidJSの効率的なリアクティビティ
import { createSignal, createMemo } from 'solid-js';

function OptimizedComponent(props) {
  // signalは必要な時のみ更新される
  const [count, setCount] = createSignal(0);

  // memoは依存関係が変更された時のみ再計算
  const expensiveValue = createMemo(() => {
    console.log('計算実行'); // 必要時のみ実行される
    return props.data.reduce(
      (sum, item) => sum + item.value,
      0
    );
  });

  return (
    <div>
      <p>Count: {count()}</p>
      <p>Expensive Value: {expensiveValue()}</p>
      <button onClick={() => setCount((c) => c + 1)}>
        Increment
      </button>
    </div>
  );
}

この例では、countが変更されてもexpensiveValueは再計算されません。これが SolidJS の真のリアクティビティの力です。

ハイドレーションの高速化技術

SolidJS では、「Progressive Hydration」と呼ばれる技術により、ハイドレーション処理を段階的に実行できます。

従来のハイドレーション: 全体を一度にハイドレーションするため、重いコンポーネントがあると全体が遅くなる

SolidJS の Progressive Hydration: 必要な部分から段階的にハイドレーションするため、ユーザーインタラクションに素早く応答できる

具体例

環境構築とセットアップ

それでは、実際に SolidJS の SSR プロジェクトを構築していきましょう。ここからが本格的な実践編です!

SolidStart プロジェクトの作成

SolidStart は、SolidJS 公式のフルスタックフレームワークです。まずはプロジェクトを作成しましょう:

bash# SolidStartプロジェクトを作成
yarn create solid

# プロジェクトディレクトリに移動
cd my-solid-app

# 依存関係をインストール
yarn install

作成されるプロジェクト構造は以下のようになります:

scssmy-solid-app/
├── src/
│   ├── routes/
│   │   ├── index.tsx
│   │   └── [...404].tsx
│   ├── components/
│   ├── app.tsx
│   └── entry-client.tsx
│   └── entry-server.tsx
├── public/
├── package.json
└── app.config.ts

app.config.ts の設定

SSR 機能を有効にするための設定を確認・調整します:

typescript// app.config.ts
import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
  // SSRを有効にする(デフォルトで有効)
  ssr: true,

  // プリレンダリング設定
  prerender: {
    routes: ['/'],
  },

  // サーバー設定
  server: {
    preset: 'node-server',
  },
});

基本的な SSR アプリケーションの作成

実際に SSR 対応のコンポーネントを作成してみましょう。まずは、サーバーサイドでデータを取得するページを作成します。

サーバーサイドデータ取得の実装

typescript// src/routes/users.tsx
import { createAsync } from '@solidjs/router';
import { cache } from '@solidjs/router';

// サーバーサイドで実行されるデータ取得関数
const getUsers = cache(async () => {
  'use server'; // サーバーサイド実行を明示

  try {
    console.log(
      'サーバーサイドでユーザーデータを取得中...'
    );
    const response = await fetch(
      'https://jsonplaceholder.typicode.com/users'
    );

    if (!response.ok) {
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    const users = await response.json();
    return users;
  } catch (error) {
    console.error(
      'ユーザーデータの取得に失敗しました:',
      error
    );
    throw error;
  }
}, 'users');

export default function UsersPage() {
  // createAsyncでサーバーサイドデータを取得
  const users = createAsync(() => getUsers());

  return (
    <div class='container mx-auto p-4'>
      <h1 class='text-2xl font-bold mb-4'>ユーザー一覧</h1>

      <Suspense
        fallback={
          <div class='text-center'>読み込み中...</div>
        }
      >
        <UserList users={users} />
      </Suspense>
    </div>
  );
}

// ユーザーリストコンポーネント
function UserList(props) {
  return (
    <div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
      <For each={props.users()}>
        {(user) => (
          <div class='bg-white rounded-lg shadow-md p-4'>
            <h3 class='text-lg font-semibold'>
              {user.name}
            </h3>
            <p class='text-gray-600'>{user.email}</p>
            <p class='text-sm text-gray-500'>
              {user.company?.name}
            </p>
          </div>
        )}
      </For>
    </div>
  );
}

この実装では、cache'use server'ディレクティブを使用してサーバーサイドでのデータ取得を明示的に指定しています。

エラーハンドリングの追加

実際のプロダクションでは、適切なエラーハンドリングが重要です:

typescript// src/components/ErrorBoundary.tsx
import { ErrorBoundary } from 'solid-js';

export function AppErrorBoundary(props) {
  return (
    <ErrorBoundary
      fallback={(err, reset) => (
        <div class='min-h-screen flex items-center justify-center bg-gray-50'>
          <div class='max-w-md w-full bg-white rounded-lg shadow-lg p-6'>
            <div class='text-center'>
              <div class='text-red-500 text-6xl mb-4'>
                ⚠️
              </div>
              <h2 class='text-xl font-semibold text-gray-900 mb-2'>
                エラーが発生しました
              </h2>
              <p class='text-gray-600 mb-4'>
                申し訳ございませんが、予期しないエラーが発生しました。
              </p>
              <button
                onClick={reset}
                class='bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors'
              >
                再試行
              </button>
            </div>
          </div>
        </div>
      )}
    >
      {props.children}
    </ErrorBoundary>
  );
}

ルーティングとデータフェッチング

SolidStart では、ファイルベースルーティングと強力なデータフェッチング機能を提供しています。

動的ルーティングの実装

typescript// src/routes/users/[id].tsx
import { useParams } from '@solidjs/router';
import { createAsync } from '@solidjs/router';
import { cache } from '@solidjs/router';

// 個別ユーザーデータ取得
const getUser = cache(async (id: string) => {
  'use server';

  try {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${id}`
    );

    if (!response.ok) {
      if (response.status === 404) {
        throw new Error('User not found');
      }
      throw new Error(
        `HTTP error! status: ${response.status}`
      );
    }

    return await response.json();
  } catch (error) {
    console.error(`ユーザー ID ${id} の取得に失敗:`, error);
    throw error;
  }
}, 'user');

export default function UserDetailPage() {
  const params = useParams();
  const user = createAsync(() => getUser(params.id));

  return (
    <div class='container mx-auto p-4'>
      <Suspense
        fallback={
          <div class='animate-pulse'>
            <div class='h-8 bg-gray-300 rounded w-1/4 mb-4'></div>
            <div class='h-4 bg-gray-300 rounded w-1/2 mb-2'></div>
            <div class='h-4 bg-gray-300 rounded w-1/3'></div>
          </div>
        }
      >
        <Show
          when={user()}
          fallback={
            <div class='text-red-500'>
              ユーザーが見つかりません
            </div>
          }
        >
          <UserDetail user={user()!} />
        </Show>
      </Suspense>
    </div>
  );
}

function UserDetail(props) {
  return (
    <div class='bg-white rounded-lg shadow-lg p-6'>
      <h1 class='text-3xl font-bold text-gray-900 mb-4'>
        {props.user.name}
      </h1>

      <div class='grid grid-cols-1 md:grid-cols-2 gap-6'>
        <div>
          <h3 class='text-lg font-semibold mb-2'>
            連絡先情報
          </h3>
          <p>
            <strong>Email:</strong> {props.user.email}
          </p>
          <p>
            <strong>Phone:</strong> {props.user.phone}
          </p>
          <p>
            <strong>Website:</strong> {props.user.website}
          </p>
        </div>

        <div>
          <h3 class='text-lg font-semibold mb-2'>住所</h3>
          <p>{props.user.address?.street}</p>
          <p>
            {props.user.address?.city},{' '}
            {props.user.address?.zipcode}
          </p>
        </div>
      </div>
    </div>
  );
}

ネストしたルーティングの活用

typescript// src/routes/dashboard.tsx (レイアウトコンポーネント)
import { Outlet } from '@solidjs/router';

export default function DashboardLayout() {
  return (
    <div class='min-h-screen bg-gray-50'>
      <nav class='bg-white shadow-sm border-b'>
        <div class='container mx-auto px-4'>
          <div class='flex items-center justify-between h-16'>
            <h1 class='text-xl font-semibold'>
              ダッシュボード
            </h1>
            <div class='flex space-x-4'>
              <A
                href='/dashboard'
                class='text-blue-600 hover:text-blue-800'
              >
                概要
              </A>
              <A
                href='/dashboard/analytics'
                class='text-blue-600 hover:text-blue-800'
              >
                分析
              </A>
            </div>
          </div>
        </div>
      </nav>

      <main class='container mx-auto px-4 py-8'>
        <Outlet />
      </main>
    </div>
  );
}

ハイドレーション最適化テクニック

ここからは、SolidJS の SSR でパフォーマンスを最大限に引き出すテクニックを学んでいきましょう。

遅延ハイドレーションの実装

重いコンポーネントは必要になるまでハイドレーションを遅延させることができます:

typescript// src/components/LazyComponent.tsx
import { lazy } from 'solid-js';
import { Dynamic } from 'solid-js/web';

// 重い処理を含むコンポーネントを遅延読み込み
const HeavyChart = lazy(() => import('./HeavyChart'));

export function DashboardOverview() {
  const [showChart, setShowChart] = createSignal(false);

  return (
    <div class='space-y-6'>
      <div class='bg-white rounded-lg shadow p-6'>
        <h2 class='text-xl font-semibold mb-4'>概要</h2>
        <p>基本的な情報をここに表示します。</p>
      </div>

      <div class='bg-white rounded-lg shadow p-6'>
        <div class='flex items-center justify-between mb-4'>
          <h2 class='text-xl font-semibold'>詳細分析</h2>
          <button
            onClick={() => setShowChart(true)}
            class='bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600'
          >
            チャートを表示
          </button>
        </div>

        <Show when={showChart()}>
          <Suspense
            fallback={
              <div class='text-center py-8'>
                チャートを読み込み中...
              </div>
            }
          >
            <HeavyChart />
          </Suspense>
        </Show>
      </div>
    </div>
  );
}

メモ化による最適化

typescript// src/utils/memoization.tsx
import { createMemo, createSignal } from 'solid-js';

export function OptimizedDataTable(props) {
  const [sortColumn, setSortColumn] = createSignal('name');
  const [sortDirection, setSortDirection] = createSignal<
    'asc' | 'desc'
  >('asc');
  const [filterText, setFilterText] = createSignal('');

  // フィルタリングとソートを効率的にメモ化
  const processedData = createMemo(() => {
    console.log('データ処理を実行'); // デバッグ用

    let filtered = props.data.filter((item) =>
      item.name
        .toLowerCase()
        .includes(filterText().toLowerCase())
    );

    return filtered.sort((a, b) => {
      const aVal = a[sortColumn()];
      const bVal = b[sortColumn()];
      const multiplier = sortDirection() === 'asc' ? 1 : -1;

      return aVal < bVal
        ? -1 * multiplier
        : aVal > bVal
        ? 1 * multiplier
        : 0;
    });
  });

  return (
    <div class='space-y-4'>
      <div class='flex space-x-4'>
        <input
          type='text'
          placeholder='名前で検索...'
          value={filterText()}
          onInput={(e) => setFilterText(e.target.value)}
          class='flex-1 px-3 py-2 border rounded-lg'
        />

        <select
          value={sortColumn()}
          onChange={(e) => setSortColumn(e.target.value)}
          class='px-3 py-2 border rounded-lg'
        >
          <option value='name'>名前</option>
          <option value='email'>メール</option>
          <option value='company'>会社</option>
        </select>
      </div>

      <div class='overflow-x-auto'>
        <table class='min-w-full bg-white border border-gray-200'>
          <thead class='bg-gray-50'>
            <tr>
              <th class='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase'>
                名前
              </th>
              <th class='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase'>
                メール
              </th>
              <th class='px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase'>
                会社
              </th>
            </tr>
          </thead>
          <tbody class='bg-white divide-y divide-gray-200'>
            <For each={processedData()}>
              {(item) => (
                <tr>
                  <td class='px-6 py-4 whitespace-nowrap'>
                    {item.name}
                  </td>
                  <td class='px-6 py-4 whitespace-nowrap'>
                    {item.email}
                  </td>
                  <td class='px-6 py-4 whitespace-nowrap'>
                    {item.company?.name}
                  </td>
                </tr>
              )}
            </For>
          </tbody>
        </table>
      </div>
    </div>
  );
}

よくあるハイドレーションエラーとその対策

実際の開発でよく遭遇するエラーとその解決方法を見てみましょう:

typescript// ❌ よくあるミス:サーバーとクライアントで異なる値
function ProblematicComponent() {
  // これはハイドレーションエラーの原因になる
  const currentTime = new Date().toLocaleString();

  return <div>現在時刻: {currentTime}</div>;
}

// ✅ 正しい実装:サーバーとクライアントで一貫性を保つ
function CorrectTimeComponent() {
  const [mounted, setMounted] = createSignal(false);
  const [currentTime, setCurrentTime] = createSignal('');

  onMount(() => {
    setMounted(true);
    setCurrentTime(new Date().toLocaleString());
  });

  return (
    <div>
      現在時刻:{' '}
      {mounted() ? currentTime() : '読み込み中...'}
    </div>
  );
}

デプロイメントと本番運用

最後に、SolidJS SSR アプリケーションを本番環境にデプロイする方法を学びましょう。

本番用ビルドの設定

typescript// app.config.ts(本番用設定)
import { defineConfig } from '@solidjs/start/config';

export default defineConfig({
  ssr: true,

  // 本番環境での最適化設定
  build: {
    target: 'esnext',
    minify: true,
  },

  // プリレンダリング設定
  prerender: {
    routes: ['/', '/about', '/contact'],
    crawlLinks: true,
  },

  // サーバー設定
  server: {
    preset: 'node-server',
    // またはVercel, Netlify, Cloudflareなど
    // preset: 'vercel'
  },

  // セキュリティ設定
  nitro: {
    experimental: {
      wasm: true,
    },
  },
});

本番用ビルドコマンド

bash# 本番用ビルド
yarn build

# 本番サーバーを起動
yarn start

Vercel へのデプロイ設定

json// vercel.json
{
  "build": {
    "env": {
      "ENABLE_VC_BUILD": "1"
    }
  },
  "routes": [
    {
      "src": "/(.*)",
      "dest": "/api/server"
    }
  ]
}

パフォーマンス監視の設定

typescript// src/utils/performance.ts
export function trackPagePerformance() {
  if (typeof window !== 'undefined') {
    // Core Web Vitalsの測定
    import('web-vitals').then(
      ({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
        getCLS(console.log);
        getFID(console.log);
        getFCP(console.log);
        getLCP(console.log);
        getTTFB(console.log);
      }
    );
  }
}

// app.tsx で使用
onMount(() => {
  trackPagePerformance();
});

環境変数の管理

typescript// src/env/server.ts
import { z } from 'zod';

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']),
  DATABASE_URL: z.string().url(),
  API_SECRET_KEY: z.string().min(32),
});

export const env = envSchema.parse(process.env);

まとめ

SolidJS SSR の完全攻略、いかがでしたでしょうか。この記事を通じて、SolidJS の SSR が単なる新しい技術ではなく、Web 開発の未来を変える可能性を秘めた革新的なアプローチであることを感じていただけたのではないでしょうか。

従来の SSR フレームワークが抱えていた課題を、SolidJS は細粒度リアクティビティという独自のアプローチで解決しています。仮想 DOM に依存しない設計により、ハイドレーション処理が劇的に高速化され、ユーザー体験の向上とパフォーマンスの最適化を同時に実現できるのです。

特に印象的なのは、開発者にとって学習コストが低く、React ライクな書き味でありながら、パフォーマンス面では圧倒的な優位性を持っていることです。これは、多くの開発チームにとって非常に魅力的な選択肢となるでしょう。

実際のプロジェクトで SolidJS SSR を採用する際は、以下のポイントを念頭に置いてください:

  • 段階的な導入: 既存プロジェクトでも、新機能から段階的に導入可能
  • パフォーマンス最優先: Core Web Vitals の改善が確実に期待できる
  • 開発体験: TypeScript 完全対応により、型安全な開発が可能
  • エコシステム: 急成長中のコミュニティとライブラリ群

SolidJS SSR は、まさに次世代の Web 開発を担う技術です。この記事で学んだ知識を活かして、ぜひ実際のプロジェクトでその素晴らしさを体験してみてください。きっと、その快適さと高いパフォーマンスに驚かれることでしょう。

Web 開発の世界は日々進化していますが、SolidJS SSR はその最前線で輝く技術の一つです。皆さんの開発体験が、この技術によってより豊かで効率的なものになることを心から願っています。

関連リンク