メタフレームワークがデフォルトの出発点に

1. 概要

数年前、新しいフロントエンドプロジェクトはReactまたはVue単体で始まることが多く、その後、ルーター、バンドラー、データ取得方法、サーバーサイドレンダリング方法を自分たちで追加していました。今では最初の質問はたいてい「Next.jsを使う?それともNuxt?」です。これらのメタフレームワークがデフォルトの出発点になってきています。

この記事ではメタフレームワークが何かを説明し、なぜそれがデフォルトで選ばれるのかを見ていきます。Next.jsとNuxtで製品ページを構築し、Spring Bootバックエンドを呼び出す方法が分かります。最後に、よく見られるエラーと使うべきでない場合を取り上げます。

2. メタフレームワークとは?

ReactとVueはUIライブラリです。これらはコンポーネントのレンダリングと状態管理を担当します。どのURLがどのページに対応するか、データをどこから読み込むか、HTMLをサーバーで生成するかブラウザで生成するかといった決定は行いません。

メタフレームワークはUIライブラリの上にある層です。これらの決定をあらかじめ提供します:

  • Next.js はReactの上に構築されています。
  • Nuxt はVueの上に構築されています。
  • 同じカテゴリにはSvelteKit、Remix/React Router、Astro、SolidStartなどがあります。

2.1. 組み込み機能

典型的なメタフレームワークが提供するもの:

  • File-based routing:ディレクトリ構造がURLを決定します。手動でルートを宣言する必要がありません。
  • 複数のレンダリングモード:SSR(毎リクエストサーバーでレンダリング)、SSG(ビルド時にあらかじめレンダリング)、および定期的な再検証やクライアント側のみのレンダリングなどのハイブリッドモード。
  • ルートと統合されたデータ取得:HTMLを送信する前に、データがサーバー上で読み込まれます。
  • Server routes:フロントエンドプロジェクト内に小さなAPIエンドポイントを直接書くことができます。通常はBFF(Backend for Frontend)として使用されます。
  • ビルドと最適化:コード分割、画像最適化、フォントとアセットの最適化がすべてあらかじめ設定されています。

これはSpring Bootとの比較ができます。Spring Frameworkはすべてのビルディングブロックを提供します。Spring Bootは約定とauto-configurationを追加して、すぐに実行できるようにします。メタフレームワークはReactとVueに対して同様の役割を果たします。

3. なぜそれがデフォルトの選択肢になったのか?

いくつかの根本的な理由があります。

初期段階での決定を減らす。 ルーター、SSR、バンドラー設定、コード分割を自分で組み合わせるのは多くの作業です。チームごとに異なる方法を使います。メタフレームワークは共通の構造を提供するため、プロジェクトに新しく参加した人がコードを読みやすくなります。

SEOと表示速度。 純粋なSPAは空のHTMLファイルを返し、JavaScriptがロードされるのを待ってから内容が表示されます。SSRまたはSSGを使用すると、ブラウザとボットが内容を含むHTMLを即座に受け取ります。これはeコマースページ、ブログ、ランディングページにとって重要です。

ルートごとの柔軟なレンダリング。 ホームページはビルド時にあらかじめレンダリングできます。製品ページはキャッシュされ、定期的に更新されます。管理ページはクライアント側でのみレンダリングされます。すべてが同じプロジェクト内にあります。

サーバー/クライアントの境界が明確。 サーバー上で実行されるコードはシークレット環境変数を読み取り、ブラウザに公開することなく内部APIを呼び出すことができます。バックエンドのCORSを有効にする必要もありません。

エコシステムとドキュメント。 Reactの公式ドキュメントはすでにフレームワークで始まることを推奨しています。多くのホスティングプラットフォームもNext.jsとNuxtのデプロイ方法をサポートしています。

4. Next.jsでの実装

Spring BootバックエンドがGET /api/products/{id}を提供していると仮定します。商品を表示し、SEOが優れており、リクエストごとにAPIを呼び出さない/products/{id}ページが必要です。

4.1. プロジェクト初期化

npx create-next-app@latest shop-web --typescript --app
cd shop-web
npm run dev

上記のコマンドはApp Routerを使用するプロジェクトを作成します。app/ディレクトリにはルートが含まれます。アプリケーションはhttp://localhost:3000で実行されます。

.env.localでバックエンドアドレスを宣言します:

API_URL=http://localhost:8080

この変数はNEXT_PUBLIC_プレフィックスがありません。したがって、これはサーバー側にのみ存在し、ブラウザに送信されるJavaScriptにバンドルされません。

4.2. Server Componentを使用した製品ページ

// app/products/[id]/page.tsx
import { notFound } from "next/navigation";
import AddToCartButton from "./AddToCartButton";

type Product = { id: number; name: string; price: number };

async function getProduct(id: string): Promise<Product | null> {
  const res = await fetch(`${process.env.API_URL}/api/products/${id}`, {
    next: { revalidate: 60 },
  });
  if (res.status === 404) return null;
  if (!res.ok) throw new Error("製品をロードできません");
  return res.json();
}

export default async function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();

  return (
    <main>
      <h1>{product.name}</h1>
      <p>{product.price.toLocaleString("vi-VN")} ₫</p>
      <AddToCartButton productId={product.id} />
    </main>
  );
}

このコードは以下のことを行っています:

  • パスapp/products/[id]/page.tsxは自動的にルート/products/:idを生成します。[id]は動的パラメータです。
  • コンポーネントはasyncで、サーバー上で実行されます。useEffectやロード状態を使わずに直接fetchを待つことができます。
  • next: { revalidate: 60 }は結果をキャッシュし、最大60秒ごとに更新します。バックエンドはすべてのアクセスで呼び出されません。
  • notFound()はバックエンドが商品が見つからないことを示すときに標準的な404ページを返します。

http://localhost:3000/products/1を開いてHTMLソースを見ます。HTMLに内容がすでに含まれていることがわかります:

<main><h1>Nón lá Huế</h1><p>150.000 ₫</p>...

4.3. Client Componentを使用したインタラクション

“買い物かごに追加”ボタンはクリックイベントを処理する必要があるため、ブラウザで実行する必要があります:

// app/products/[id]/AddToCartButton.tsx
"use client";

import { useState } from "react";

export default function AddToCartButton({ productId }: { productId: number }) {
  const [added, setAdded] = useState(false);

  return (
    <button onClick={() => setAdded(true)} disabled={added}>
      {added ? "追加済み" : `商品#${productId}をカートに追加`}
    </button>
  );
}

ディレクティブ"use client"は境界をマークします。このコンポーネントとそれがインポートするもののみがJavaScriptとしてブラウザに送信されます。ページの残りの部分はHTMLのみです。

5. Nuxtでの同様のアプローチ

NuxtはVueのスタイルで同じ問題を解決します。今回、BFFとして機能するサーバールートを追加します。ブラウザはNuxtとのみ通信し、NuxtがSpring Bootを呼び出します。

プロジェクトを初期化する:

npm create nuxt@latest shop-nuxt
cd shop-nuxt
npm run dev

nuxt.config.tsでの設定:

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    apiUrl: "http://localhost:8080", // サーバー側のみ
  },
  routeRules: {
    "/": { prerender: true },          // ビルド時にあらかじめレンダリング
    "/products/**": { swr: 60 },       // キャッシュ、60秒ごとにバックグラウンドで更新
    "/admin/**": { ssr: false },       // クライアント側のみでレンダリング
  },
});

routeRulesは「ルートごとのレンダリング」の利点をはっきり示しています。3つの異なる戦略が数行の設定内に含まれます。キーapiUrlはpublicの外にあるため、ブラウザに公開されません。デプロイ時に環境変数NUXT_API_URLでこの値をオーバーライドできます。

BFFとしてのサーバールート:

// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, "id");
  const config = useRuntimeConfig(event);
  return await $fetch(`${config.apiUrl}/api/products/${id}`);
});

ファイル名[id].get.tsはNuxtアプリケーション内にエンドポイントGET /api/products/:idを生成します。

製品を表示するページ:

<!-- pages/products/[id].vue -->
<script setup lang="ts">
type Product = { id: number; name: string; price: number };

const route = useRoute();
const { data: product, error } = await useFetch<Product>(
  `/api/products/${route.params.id}`
);
</script>

<template>
  <main>
    <p v-if="error">商品が見つかりません。</p>
    <template v-else-if="product">
      <h1>{{ product.name }}</h1>
      <p>{{ product.price.toLocaleString("vi-VN") }} ₫</p>
    </template>
  </main>
</template>

useFetchは最初のロード時にサーバー上で実行されます。Nuxtはその後データをクライアントに転送するため、ブラウザはハイドレーション時にAPIを再度呼び出しません。ユーザーがクライアント側でページを切り替えるときに、リクエストが同じオリジンの/api/products/:idに送信されるため、CORSは不要です。

6. Spring Bootバックエンド側

両方のフロントエンドが同じAPIを呼び出します:

// Product.java
public record Product(long id, String name, long price) {}
// ProductCatalog.java
@Component
public class ProductCatalog {
    private final Map<Long, Product> products = Map.of(
            1L, new Product(1, "Nón lá Huế", 150_000),
            2L, new Product(2, "Áo dài lụa", 1_200_000));

    public Optional<Product> findById(long id) {
        return Optional.ofNullable(products.get(id));
    }
}
// ProductController.java
@RestController
@RequestMapping("/api/products")
public class ProductController {
    private final ProductCatalog catalog;

    public ProductController(ProductCatalog catalog) {
        this.catalog = catalog;
    }

    @GetMapping("/{id}")
    public ResponseEntity<Product> getProduct(@PathVariable long id) {
        return ResponseEntity.of(catalog.findById(id));
    }
}

ResponseEntity.ofは製品がある場合は200とボディを返し、Optionalが空の場合は404を返します。セクション4.2のNext.jsページはこの正確な404コードに依存してnotFound()を呼び出します。

コントローラーに**@CrossOriginがない**ことに注意してください。すべてのリクエストはメタフレームワークのサーバーから発生し、ブラウザからではありません。そのため、バックエンドは内部ネットワーク内に配置できます。

このAPIコントラクトは両方のフロントエンドが依存するため、テストする必要があります:

@WebMvcTest(ProductController.class)
@Import(ProductCatalog.class)
class ProductControllerTest {

    @Autowired
    MockMvc mockMvc;

    @Test
    void returnsProductWhenFound() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/1")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(200);
        assertThat(result.getResponse().getContentAsString(StandardCharsets.UTF_8))
                .contains("\"name\":\"Nón lá Huế\"")
                .contains("\"price\":150000");
    }

    @Test
    void returns404WhenMissing() throws Exception {
        MvcResult result = mockMvc.perform(get("/api/products/99")).andReturn();

        assertThat(result.getResponse().getStatus()).isEqualTo(404);
    }
}

最初のテストはフロントエンドページが読む(name、price)JSON構造をチェックします。2番目のテストは404の正しい動作を保証します。なぜなら、フロントエンドのnotFound()ロジックがそれに依存しているからです。

7. よくあるエラー

秘密変数がクライアントに漏洩する。 Next.jsでは、NEXT_PUBLIC_プレフィックスを持つすべての変数がバンドルに組み込まれます。Nuxtでも、runtimeConfig.public内のすべてのものが同じです。APIキーをそこに置かないでください。

注記: Server ComponentがClient Componentに誤ってインポートされると、サーバーコードがブラウザにプルされるか、ビルドエラーが発生します。ファイルがどこで実行されるかを明確に分離してください。実際に相互作用が必要なリーフコンポーネントにのみ"use client"をマークします。

サーバーでレンダリング中にブラウザAPIを使用する。 SSRで実行されるコード内でwindowまたはlocalStorageを呼び出すと、window is not definedエラーが発生します。これらのセクションをClient ComponentまたはvueのonMountedに移動させてください。

Hydration mismatch。 このエラーはサーバーでレンダリングされたHTMLがクライアントでの最初のレンダリングと異なる場合に発生します。一般的な原因はnew Date()、Math.random()、またはサーバーとブラウザ間で異なるロケール番号形式です。

キャッシュメカニズムを理解していない。 データが「更新されない」ことは、通常バックエンドではなくフレームワークのキャッシュが原因です。各ルートに対してrevalidateまたはswrを意図的に宣言してください。

メタフレームワークをメインバックエンドにすることになる。 Server routesはBFFに非常に便利です。しかし、ビジネスロジック、トランザクション、データベースアクセスをそこに配置すると、2つのバックエンドを保守する必要があります。ビジネスロジックはSpring Bootに保つ必要があります。

8. 使うべきでない場合

デフォルトは常に正しいわけではありません。以下の場合は別のオプションを検討してください:

  • アプリケーションが完全にログインの背後にある場合、SEOが不要です。例:内部ダッシュボード、管理ツール。Viteで構築されたSPAはより単純になる可能性があります:静的ファイルとしてデプロイでき、Node.jsサーバーは必要ありません。
  • インフラストラクチャがNode.jsを実行できない、または運用チームはJARのデプロイにしか慣れていません。SSRは実行、監視、スケーリングするための独立したランタイムが必要です。
  • ページが主に静的コンテンツで、相互作用が少ない場合。 静的サイトジェネレータ、またはSpring Boot内で直接レンダリングするThymeleafで十分な場合があります。
  • ウィジェット、既存のページ内のマイクロフロントエンド、またはデスクトップアプリケーションなどの埋め込みアプリケーション。 この場合、メタフレームワークのルーティングとSSRは負荷になります。
  • 小さなチーム、サーバー/クライアントモデルに慣れていない。 Server Component、hydration、キャッシュの概念には独自の学習曲線があります。これらを理解していないと、除去が難しいエラーに遭遇しやすくなります。

1つの単純な質問があなたの決定を助けます:サーバーからすぐに内容を含むHTMLが必要なページはありますか? 答えが「いいえ」の場合、メタフレームワークの最大の利点はほぼ消えてしまいます。

9. 結論

Next.jsとNuxtのようなメタフレームワークには、ルーティング、レンダリングモード、データ取得、サーバールートがあらかじめパッケージ化されています。ReactとVueに対する役割は、Springに対するSpring Bootの役割に似ています。初期段階での決定を減らし、SEOと表示速度をサポートし、ルートごとにレンダリング戦略を選択でき、サーバー/クライアントの境界を明確にするため、デフォルトの選択肢になっています。

実際には、効率的なモデルはメタフレームワークがプレゼンテーションとBFFを担当し、Spring Bootがビジネスロジックとデータを保つことです。サーバー/クライアントの境界とキャッシュメカニズムを理解する必要があります。そして、SEOが不要な内部アプリケーションの場合、純粋なSPAはまだ合理的な選択肢です。

tech-trendsnextjsnuxtfrontendspring-boot