T-CREATOR

Nuxt RSC/Edge 時代の境界設計:クライアント/サーバの責務分担とデータ流通

Nuxt RSC/Edge 時代の境界設計:クライアント/サーバの責務分担とデータ流通

Nuxt 3 の登場により、サーバーとクライアントの境界線がこれまで以上に曖昧になってきました。特に Edge Runtime や Server Components の概念が導入されたことで、どこで何を処理すべきか、データをどう流通させるべきかという設計判断が非常に重要になっています。

この記事では、Nuxt 3 における最新のアーキテクチャパターンを踏まえ、クライアント/サーバの責務分担とデータ流通の最適な設計方法について解説していきます。従来の SSR(Server-Side Rendering)だけでは対応しきれない、現代の Web アプリケーション設計の勘所をお伝えしますね。

背景

Nuxt 3 がもたらした新しいパラダイム

Nuxt 3 は Vue 3 をベースに、Nitro という強力なサーバーエンジンを搭載しています。この Nitro により、従来の Node.js 環境だけでなく、Cloudflare Workers や Vercel Edge Functions といった Edge Runtime でも実行できるようになりました。

さらに、Server Components の概念が導入され、コンポーネント単位でサーバー/クライアントの実行場所を選択できるようになっています。これは React の RSC(React Server Components)に似た考え方ですが、Nuxt 独自の実装となっているのです。

以下の図は、Nuxt 3 における実行環境の全体像を示しています。

mermaidflowchart TB
    user["ユーザー<br/>ブラウザ"] -->|リクエスト| edge["Edge Runtime<br/>Cloudflare/Vercel"]
    edge -->|静的配信| static["静的アセット<br/>JS/CSS/画像"]
    edge -->|動的処理| nitro["Nitro サーバー<br/>エンジン"]
    nitro -->|SSR| vue["Vue SSR<br/>レンダリング"]
    nitro -->|API| api["API Routes<br/>サーバー関数"]
    api -->|データ取得| db[("データベース<br/>外部 API")]
    vue -->|ハイドレーション| client["クライアント<br/>Vue アプリ"]
    static -->|読み込み| client

図で理解できる要点:

  • Edge Runtime がフロントエンドとして最初のリクエストを受ける
  • Nitro がサーバーサイドの中核として SSR と API の両方を担当
  • クライアントサイドではハイドレーションにより Vue アプリが動作

従来の SSR との違い

従来の Nuxt 2 では、サーバーサイドとクライアントサイドの境界は比較的明確でした。asyncDatafetch といったライフサイクルフックを使って、サーバーでデータ取得し、クライアントでレンダリングするという流れです。

しかし Nuxt 3 では、以下の新しい概念が加わりました。

#概念説明実行場所
1Server Componentsサーバー専用コンポーネントサーバーのみ
2Composables再利用可能なロジックサーバー/クライアント両方
3Server RoutesAPI エンドポイントサーバーのみ
4Edge Functionsエッジでの軽量処理Edge Runtime
5Hybrid Renderingページ単位のレンダリング戦略設定による

この多様性により、より細かい粒度で最適化が可能になった一方で、設計の複雑さも増しています。

課題

クライアント/サーバ境界の曖昧さ

Nuxt 3 の柔軟性は強力ですが、同時にいくつかの課題も生み出しています。最も大きな課題は、「どこで何を実行すべきか」という判断基準が曖昧になることです。

以下の図は、境界設計を誤った場合に発生する問題を示しています。

mermaidflowchart LR
    subgraph problem1["❌ 問題パターン1"]
        c1["クライアント"] -->|機密データ| api1["API キー露出"]
    end

    subgraph problem2["❌ 問題パターン2"]
        s2["サーバー"] -->|重い処理| bottleneck["ボトルネック"]
    end

    subgraph problem3["❌ 問題パターン3"]
        duplicate["重複データ取得"] -->|無駄| network["ネットワーク負荷"]
    end

    style problem1 fill:#ffe6e6
    style problem2 fill:#ffe6e6
    style problem3 fill:#ffe6e6

図で理解できる要点:

  • クライアントで機密情報を扱うとセキュリティリスクが発生
  • サーバーに処理を集中させるとパフォーマンス低下
  • データ取得の重複は無駄なネットワーク負荷を生む

よくある設計ミス

実際の開発現場でよく見られる設計ミスを挙げてみましょう。

1. API キーのクライアント露出

クライアントサイドで直接外部 API を呼び出すコードを書いてしまうケースです。これは環境変数の扱いを誤ると、API キーがバンドルされたファイルに含まれてしまいます。

2. サーバーでの過剰な処理

すべての処理をサーバーサイドで行うと、サーバーの負荷が高まり、レスポンスタイムが遅くなります。特に Edge Runtime では実行時間の制限があるため、注意が必要です。

3. データフェッチの重複

SSR 時にサーバーで取得したデータを、クライアントサイドで再度取得してしまうパターンです。これはハイドレーションの仕組みを理解していないことが原因で起こります。

セキュリティとパフォーマンスのトレードオフ

セキュリティを重視してすべてをサーバーで処理すると、パフォーマンスが犠牲になります。一方、パフォーマンスを優先してクライアントサイドで処理を増やすと、セキュリティリスクが高まるのです。

このバランスを取ることが、現代の Web アプリケーション設計における最大の課題と言えるでしょう。

解決策

責務分担の基本原則

クライアント/サーバの責務を明確に分けるための基本原則を定義しましょう。以下の図は、理想的な責務分担を示しています。

mermaidflowchart TB
    subgraph server["🖥️ サーバーサイドの責務"]
        s1["認証・認可処理"]
        s2["機密データへのアクセス"]
        s3["重いデータ加工"]
        s4["初期データ取得(SSR)"]
    end

    subgraph edge["⚡ Edge の責務"]
        e1["地理的ルーティング"]
        e2["キャッシュ制御"]
        e3["軽量な変換処理"]
        e4["A/B テスト分岐"]
    end

    subgraph client["🌐 クライアントサイドの責務"]
        c1["UI インタラクション"]
        c2["リアルタイム更新"]
        c3["ローカルステート管理"]
        c4["クライアント専用 API 呼び出し"]
    end

    style server fill:#e3f2fd
    style edge fill:#fff3e0
    style client fill:#f3e5f5

図で理解できる要点:

  • サーバーは機密性の高い処理とデータアクセスを担当
  • Edge は地理的に最適化された軽量処理を実行
  • クライアントは UI とインタラクションに専念

サーバーサイドの責務

サーバーサイドでは、以下のような処理を行うべきです。

1. 認証・認可の処理

ユーザー認証やアクセス権限の確認は、必ずサーバーサイドで実行します。以下は、サーバーミドルウェアでの認証チェックの例です。

typescript// server/middleware/auth.ts
export default defineEventHandler(async (event) => {
  // リクエストヘッダーからトークンを取得
  const token = getCookie(event, 'auth-token');

  if (!token) {
    // 未認証の場合はエラーを返す
    throw createError({
      statusCode: 401,
      message: 'Unauthorized',
    });
  }

  // トークンの検証(サーバーのみで実行)
  const user = await verifyToken(token);

  // コンテキストにユーザー情報を設定
  event.context.user = user;
});

このミドルウェアは、すべての API リクエストに対して実行され、認証状態をチェックします。

2. 機密データへのアクセス

データベースや外部 API へのアクセスは、サーバーサイドの専売特許です。API キーや接続情報を安全に扱えます。

typescript// server/api/users/[id].ts
export default defineEventHandler(async (event) => {
  // ルートパラメータからIDを取得
  const id = getRouterParam(event, 'id');

  // 環境変数からAPIキーを取得(クライアントには露出しない)
  const apiKey = useRuntimeConfig().apiSecret;

  // 外部APIへのリクエスト
  const response = await $fetch(
    `https://api.example.com/users/${id}`,
    {
      headers: {
        Authorization: `Bearer ${apiKey}`,
      },
    }
  );

  return response;
});

環境変数 apiSecret は、サーバーサイドでのみアクセス可能です。クライアントには決して送信されません。

3. 初期データの取得と SSR

ページの初回表示に必要なデータは、サーバーサイドで取得して SSR します。これにより、SEO 対策とパフォーマンス向上を実現できます。

typescript// composables/useUserData.ts
export const useUserData = async (id: string) => {
  // サーバー/クライアント両方で動作するデータ取得
  const { data, error } = await useFetch(
    `/api/users/${id}`,
    {
      // SSR時はサーバーで実行、その後はクライアントでキャッシュ
      key: `user-${id}`,
      // 重複リクエストを防ぐ
      dedupe: 'defer',
    }
  );

  return { data, error };
};

useFetch は Nuxt 3 の組み込み Composable で、SSR 時はサーバーで実行され、ハイドレーション後はクライアントで動作します。

Edge Runtime の活用

Edge Runtime は、地理的に分散したエッジロケーションで軽量な処理を実行できる環境です。

1. 地理的ルーティング

ユーザーの位置情報に基づいて、最適なコンテンツを配信できます。

typescript// server/routes/region.ts
export default defineEventHandler((event) => {
  // リクエストヘッダーから地域情報を取得
  const country = getHeader(event, 'cf-ipcountry') || 'US';

  // 地域別のコンテンツを返す
  return {
    region: country,
    content: getRegionalContent(country),
  };
});

Cloudflare Workers などの Edge Runtime では、cf-ipcountry ヘッダーから国情報を取得できます。

2. キャッシュ制御と CDN 統合

Edge でキャッシュを制御することで、レスポンスタイムを大幅に改善できます。

typescript// server/api/posts.ts
export default defineEventHandler(async (event) => {
  // キャッシュヘッダーを設定
  setHeader(
    event,
    'Cache-Control',
    's-maxage=3600, stale-while-revalidate=86400'
  );

  // データ取得
  const posts = await fetchPosts();

  return posts;
});

s-maxage で CDN キャッシュの有効期限を、stale-while-revalidate で古いキャッシュの配信期間を設定しています。

クライアントサイドの責務

クライアントサイドでは、ユーザーとのインタラクションに関する処理を担当します。

1. UI インタラクションとリアルタイム更新

ボタンのクリックやフォーム入力など、ユーザーの操作に即座に反応する処理はクライアントサイドで行います。

vue<script setup lang="ts">
// クライアント専用のステート管理
const count = ref(0);
const isLoading = ref(false);

// クライアントサイドでのインタラクション処理
const increment = async () => {
  isLoading.value = true;

  // APIへの非同期リクエスト
  await $fetch('/api/increment', {
    method: 'POST',
    body: { count: count.value },
  });

  count.value++;
  isLoading.value = false;
};
</script>

<template>
  <button @click="increment" :disabled="isLoading">
    カウント: {{ count }}
  </button>
</template>

この例では、ボタンのクリックイベントをクライアントサイドで処理し、必要に応じて API を呼び出しています。

2. ローカルステート管理

ページ遷移を伴わない UI の状態変化は、クライアントサイドのステートで管理します。

typescript// stores/ui.ts
export const useUIStore = defineStore('ui', () => {
  // モーダルの開閉状態
  const isModalOpen = ref(false);

  // サイドバーの表示状態
  const isSidebarVisible = ref(true);

  // テーマ設定(ダークモード)
  const theme = ref<'light' | 'dark'>('light');

  // アクション
  const toggleModal = () => {
    isModalOpen.value = !isModalOpen.value;
  };

  const toggleSidebar = () => {
    isSidebarVisible.value = !isSidebarVisible.value;
  };

  return {
    isModalOpen,
    isSidebarVisible,
    theme,
    toggleModal,
    toggleSidebar,
  };
});

Pinia を使ったステート管理により、コンポーネント間で状態を共有できます。

データフローの設計パターン

サーバーとクライアント間のデータ流通を最適化するためのパターンを紹介します。

mermaidsequenceDiagram
    participant U as ユーザー
    participant C as クライアント
    participant S as サーバー
    participant D as DB/API

    U->>C: ページアクセス
    C->>S: SSRリクエスト
    S->>D: 初期データ取得
    D-->>S: データ返却
    S-->>C: HTML + データ
    C-->>U: 初回表示

    Note over C: ハイドレーション

    U->>C: ユーザー操作
    C->>S: API呼び出し
    S->>D: データ取得/更新
    D-->>S: 結果返却
    S-->>C: JSON レスポンス
    C-->>U: UI 更新

図で理解できる要点:

  • SSR 時にサーバーが初期データを取得して HTML と共に送信
  • ハイドレーション後、クライアントがインタラクティブに動作
  • ユーザー操作時のみ API 経由でデータ更新

具体例

実践的なアーキテクチャ例

ここでは、ブログアプリケーションを例に、具体的な実装パターンを見ていきましょう。

要件:

  • 記事一覧と詳細ページの SEO 対策
  • ユーザー認証とコメント機能
  • リアルタイムのいいね機能
  • Edge でのキャッシュ最適化

以下は、この要件を満たすためのアーキテクチャ図です。

mermaidflowchart TB
    subgraph client["クライアント層"]
        ui["Vue コンポーネント<br/>UI/インタラクション"]
        state["Pinia ストア<br/>ローカルステート"]
    end

    subgraph edge["Edge 層"]
        cache["CDN キャッシュ<br/>静的アセット"]
        routing["地域別ルーティング"]
    end

    subgraph server["サーバー層"]
        ssr["SSR エンジン<br/>初期レンダリング"]
        api["API Routes<br/>ビジネスロジック"]
        auth["認証ミドルウェア"]
    end

    subgraph data["データ層"]
        db[("Prisma + PostgreSQL<br/>記事・ユーザー")]
        redis[("Redis<br/>セッション・キャッシュ")]
    end

    ui --> state
    ui --> edge
    edge --> cache
    edge --> routing
    routing --> ssr
    ssr --> api
    api --> auth
    auth --> db
    api --> redis

    style client fill:#f3e5f5
    style edge fill:#fff3e0
    style server fill:#e3f2fd
    style data fill:#e8f5e9

図で理解できる要点:

  • クライアント層は UI とステート管理に専念
  • Edge 層でキャッシュと地域最適化を実施
  • サーバー層で SSR とビジネスロジックを処理
  • データ層は PostgreSQL と Redis で永続化とキャッシュを分離

記事一覧ページの実装

記事一覧ページは、SSR で初期データを取得し、SEO を最適化します。

vue<!-- pages/index.vue -->
<script setup lang="ts">
// SSR時にサーバーで実行される
const { data: posts } = await useFetch('/api/posts', {
  // キャッシュキーを指定
  key: 'posts-list',
  // 10秒間はキャッシュを使用
  getCachedData(key) {
    return useNuxtData(key).data.value;
  },
});

// SEO設定
useHead({
  title: '記事一覧 - My Blog',
  meta: [
    {
      name: 'description',
      content: '最新の技術記事をお届けします',
    },
  ],
});
</script>

<template>
  <div class="posts-list">
    <article v-for="post in posts" :key="post.id">
      <NuxtLink :to="`/posts/${post.id}`">
        <h2>{{ post.title }}</h2>
        <p>{{ post.excerpt }}</p>
      </NuxtLink>
    </article>
  </div>
</template>

このコンポーネントは、useFetch により SSR 時にサーバーで記事データを取得します。

サーバーサイドの API 実装:

typescript// server/api/posts/index.ts
export default defineEventHandler(async (event) => {
  // キャッシュヘッダーを設定(Edge/CDNでキャッシュ)
  setHeader(
    event,
    'Cache-Control',
    's-maxage=60, stale-while-revalidate=300'
  );

  // データベースから記事を取得
  const posts = await prisma.post.findMany({
    select: {
      id: true,
      title: true,
      excerpt: true,
      createdAt: true,
      author: {
        select: { name: true },
      },
    },
    orderBy: { createdAt: 'desc' },
    take: 20,
  });

  return posts;
});

Cache-Control ヘッダーにより、Edge でのキャッシュを有効にしています。

記事詳細ページの実装

記事詳細ページでは、認証状態に応じてコメント機能を出し分けます。

vue<!-- pages/posts/[id].vue -->
<script setup lang="ts">
const route = useRoute();
const id = route.params.id;

// 記事データの取得(SSR対応)
const { data: post } = await useFetch(`/api/posts/${id}`);

// 認証状態の取得(クライアント/サーバー両方で動作)
const { data: user } = await useFetch('/api/auth/user');

// いいねボタンの状態(クライアントサイド)
const likes = ref(post.value?.likes || 0);
const isLiked = ref(false);

// いいね機能(クライアントサイドのみ)
const toggleLike = async () => {
  if (!user.value) {
    alert('ログインが必要です');
    return;
  }

  isLiked.value = !isLiked.value;
  likes.value += isLiked.value ? 1 : -1;

  // APIに送信(バックグラウンド)
  await $fetch(`/api/posts/${id}/like`, {
    method: 'POST',
    body: { liked: isLiked.value },
  });
};
</script>

認証状態の確認とデータ取得は SSR で行い、いいね機能はクライアントサイドで処理します。

いいね機能の API 実装:

typescript// server/api/posts/[id]/like.post.ts
export default defineEventHandler(async (event) => {
  // 認証チェック(ミドルウェアで実行済み)
  const user = event.context.user;
  if (!user) {
    throw createError({
      statusCode: 401,
      message: 'Unauthorized',
    });
  }

  // リクエストボディから状態を取得
  const { liked } = await readBody(event);
  const postId = getRouterParam(event, 'id');

  // いいね状態を更新
  if (liked) {
    await prisma.like.create({
      data: { userId: user.id, postId },
    });
  } else {
    await prisma.like.delete({
      where: { userId_postId: { userId: user.id, postId } },
    });
  }

  // 最新のいいね数を返す
  const count = await prisma.like.count({
    where: { postId },
  });

  return { count };
});

認証が必要な処理は、必ずサーバーサイドで実行します。

コメント機能の実装

コメント投稿は、サーバーサイドでバリデーションと保存を行います。

vue<!-- components/CommentForm.vue -->
<script setup lang="ts">
const props = defineProps<{
  postId: string;
}>();

const emit = defineEmits<{
  (e: 'submitted'): void;
}>();

// フォームの状態(クライアントサイド)
const comment = ref('');
const isSubmitting = ref(false);

// コメント送信処理
const submitComment = async () => {
  if (!comment.value.trim()) return;

  isSubmitting.value = true;

  try {
    await $fetch(`/api/posts/${props.postId}/comments`, {
      method: 'POST',
      body: { content: comment.value },
    });

    // 成功時はフォームをクリア
    comment.value = '';
    emit('submitted');
  } catch (error) {
    alert('コメントの投稿に失敗しました');
  } finally {
    isSubmitting.value = false;
  }
};
</script>

フォームの状態管理はクライアントサイドで行い、送信処理はサーバー API を呼び出します。

コメント投稿の API 実装:

typescript// server/api/posts/[id]/comments.post.ts
import { z } from 'zod';

// バリデーションスキーマ
const commentSchema = z.object({
  content: z.string().min(1).max(1000),
});

export default defineEventHandler(async (event) => {
  // 認証チェック
  const user = event.context.user;
  if (!user) {
    throw createError({
      statusCode: 401,
      message: 'ログインが必要です',
    });
  }

  // リクエストボディのバリデーション
  const body = await readBody(event);
  const result = commentSchema.safeParse(body);

  if (!result.success) {
    throw createError({
      statusCode: 400,
      message: 'Invalid input',
    });
  }

  // データベースに保存
  const postId = getRouterParam(event, 'id');
  const comment = await prisma.comment.create({
    data: {
      content: result.data.content,
      userId: user.id,
      postId,
    },
    include: {
      user: {
        select: { name: true, avatar: true },
      },
    },
  });

  return comment;
});

Zod を使ったバリデーションにより、不正なデータの保存を防ぎます。

Hybrid Rendering の活用

ページごとに異なるレンダリング戦略を適用できます。

typescript// nuxt.config.ts
export default defineNuxtConfig({
  // ルート別のレンダリング設定
  routeRules: {
    // 静的ページ(ビルド時に生成)
    '/about': { prerender: true },

    // ISR(一定期間キャッシュして再生成)
    '/posts/**': {
      swr: 3600, // 1時間ごとに再生成
      cache: {
        maxAge: 3600,
      },
    },

    // SPA(クライアントサイドのみ)
    '/dashboard/**': { ssr: false },

    // Edge(エッジで動的生成)
    '/api/**': {
      cache: false,
      headers: {
        'Cache-Control': 'no-cache',
      },
    },
  },
});

ページの特性に応じて、最適なレンダリング方法を選択できます。

各戦略の使い分け:

#レンダリング方法適用ページメリットデメリット
1Static(Prerender)About, 利用規約高速・SEO 強い更新に再ビルド必要
2ISR(SWR)ブログ記事高速・最新データやや複雑
3SSR(Server-Side)認証ページリアルタイムサーバー負荷高
4SPA(Client-Side)ダッシュボードインタラクティブSEO 弱い
5EdgeAPI・軽量処理地理的最適化実行制限あり

まとめ

Nuxt 3 における クライアント/サーバの境界設計は、アプリケーションのパフォーマンスとセキュリティを大きく左右します。この記事でお伝えした重要なポイントをまとめますね。

責務分担の原則:

サーバーサイドでは認証・認可、機密データアクセス、初期データ取得を担当し、クライアントサイドは UI インタラクションとローカルステート管理に専念しましょう。Edge Runtime は地理的最適化とキャッシュ制御に活用できます。

データフローの最適化:

SSR で初期データを取得し、ハイドレーション後はクライアントサイドでインタラクティブに動作させることで、SEO とユーザー体験の両立が可能になります。useFetchuseAsyncData などの Composable を活用することで、重複リクエストを防げるのです。

セキュリティとパフォーマンスのバランス:

機密情報は必ずサーバーサイドで扱い、API キーやトークンをクライアントに露出させないようにしましょう。一方で、すべてをサーバーで処理するとパフォーマンスが低下するため、適切な責務分担が重要です。

Hybrid Rendering の活用:

ページの特性に応じて、Static、ISR、SSR、SPA、Edge といった異なるレンダリング戦略を使い分けることで、最適なパフォーマンスを実現できます。

Nuxt 3 の柔軟性を最大限に活かすためには、このような設計原則を理解し、プロジェクトの要件に応じて適切に適用することが大切です。Edge Runtime や Server Components といった新しい概念も、基本原則を押さえていれば恐れることはありません。

ぜひこの記事を参考に、あなたのプロジェクトでも最適な境界設計を実現してください。

関連リンク