はじめに/ガイド/SPA

Next.js でシングルページアプリケーション (SPA) を構築する方法

Next.js はシングルページアプリケーション (SPA) の構築を完全にサポートしています。

これには、プリフェッチによる高速なルート遷移、クライアントサイドデータフェッチ、ブラウザ API の使用、サードパーティクライアントライブラリとの統合、静的ルートの作成などが含まれます。

既存の SPA がある場合、コードを大幅に変更せずに Next.js に移行できます。その後、必要に応じてサーバー機能を段階的に追加できます。

シングルページアプリケーション (SPA) とは?

SPA の定義はさまざまです。ここでは「厳密な SPA」を次のように定義します:

  • クライアントサイドレンダリング (CSR): アプリは1つの HTML ファイル (例: index.html) で提供されます。すべてのルート、ページ遷移、データフェッチはブラウザ上の JavaScript で処理されます。
  • フルページリロードなし: 各ルートに対して新しいドキュメントを要求する代わりに、クライアントサイド JavaScript が現在のページの DOM を操作し、必要に応じてデータを取得します。

厳密な SPA では、ページがインタラクティブになる前に大量の JavaScript を読み込む必要があることが多く、クライアントデータのウォーターフォールの管理も課題になりがちです。Next.js で SPA を構築することでこれらの問題に対処できます。

Next.js で SPA を構築する理由

Next.js は JavaScript バンドルを自動的にコード分割し、異なるルートへの複数の HTML エントリポイントを生成できます。これにより、クライアントサイドで不要な JavaScript コードを読み込む必要がなくなり、バンドルサイズが縮小され、ページ読み込みが高速化されます。

next/link コンポーネントは自動的にルートをプリフェッチするため、厳密な SPA のような高速なページ遷移を実現しながら、アプリケーションのルーティング状態を URL に保持してリンクや共有が可能です。

Next.js は静的サイトや、すべてがクライアントサイドでレンダリングされる厳密な SPA として始めることができます。プロジェクトが成長した場合、必要に応じて React サーバーコンポーネントサーバーアクション などのサーバー機能を段階的に追加できます。

SPA 構築でよく使われるパターンと、Next.js がそれらをどのように解決するかを見ていきましょう。

Context Provider 内で React の use を使用する

親コンポーネント(またはレイアウト)でデータを取得し、Promise を返してから、React の use フック でクライアントコンポーネント内で値をアンラップすることを推奨します。

Next.js はサーバー上で早期にデータフェッチを開始できます。この例では、アプリケーションのエントリポイントであるルートレイアウトです。サーバーはすぐにクライアントへのレスポンスのストリーミングを開始できます。

データフェッチをルートレイアウトに「引き上げる」ことで、Next.js はアプリケーション内の他のコンポーネントよりも前にサーバー上で指定されたリクエストを開始します。これにより、クライアントウォーターフォールが排除され、クライアントとサーバー間の複数回の往復が防止されます。また、サーバーがデータベースに近い(理想的には同一場所にある)ため、パフォーマンスが大幅に向上する可能性があります。

例えば、ルートレイアウトを更新して Promise を呼び出しますが、await はしません

import { UserProvider } from './user-provider'
import { getUser } from './user' // サーバーサイド関数

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  let userPromise = getUser() // await しない

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}
import { UserProvider } from './user-provider'
import { getUser } from './user' // サーバーサイド関数

export default function RootLayout({ children }) {
  let userPromise = getUser() // await しない

  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}

単一の Promise を遅延させてクライアントコンポーネントに渡すことができますが、このパターンは通常、React コンテキストプロバイダーと組み合わせて使用されます。これにより、カスタム React フックを使用してクライアントコンポーネントから簡単にアクセスできます。

Promise を React コンテキストプロバイダーに転送できます:

'use client';

import { createContext, useContext, ReactNode } from 'react';

type User = any;
type UserContextType = {
  userPromise: Promise<User | null>;
};

const UserContext = createContext<UserContextType | null>(null);

export function useUser(): UserContextType {
  let context = useContext(UserContext);
  if (context === null) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}

export function UserProvider({
  children,
  userPromise
}: {
  children: ReactNode;
  userPromise: Promise<User | null>;
}) {
  return (
    <UserContext.Provider value={{ userPromise }}>
      {children}
    </UserContext.Provider>
  );
}
'use client'

import { createContext, useContext, ReactNode } from 'react'

const UserContext = createContext(null)

export function useUser() {
  let context = useContext(UserContext)
  if (context === null) {
    throw new Error('useUser must be used within a UserProvider')
  }
  return context
}

export function UserProvider({ children, userPromise }) {
  return (
    <UserContext.Provider value={{ userPromise }}>
      {children}
    </UserContext.Provider>
  )
}

最後に、任意のクライアントコンポーネントで useUser() カスタムフックを呼び出し、Promise をアンラップできます:

'use client'

import { use } from 'react'
import { useUser } from './user-provider'

export function Profile() {
  const { userPromise } = useUser()
  const user = use(userPromise)

  return '...'
}
'use client'

import { use } from 'react'
import { useUser } from './user-provider'

export function Profile() {
  const { userPromise } = useUser()
  const user = use(userPromise)

  return '...'
}

Promise を消費するコンポーネント(上記の Profile など)はサスペンドされます。これにより、部分的なハイドレーションが可能になります。JavaScript の読み込みが完了する前に、ストリーミングされプリレンダリングされた HTML を確認できます。

SWR を使用した SPA

SWR はデータフェッチ用の人気のある React ライブラリです。

SWR 2.3.0(および React 19+)では、既存の SWR ベースのクライアントデータフェッチコードと並行してサーバー機能を段階的に採用できます。これは上記の use() パターンの抽象化です。つまり、データフェッチをクライアントとサーバーサイドの間で移動したり、両方を使用したりできます:

  • クライアントのみ: useSWR(key, fetcher)
  • サーバーのみ: useSWR(key) + RSC 提供データ
  • 混合: useSWR(key, fetcher) + RSC 提供データ

例えば、アプリケーションを <SWRConfig>fallback でラップします:

import { SWRConfig } from 'swr'
import { getUser } from './user' // サーバーサイド関数

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // ここでは getUser() を await しません
          // このデータを読み取るコンポーネントのみがサスペンドされます
          '/api/user': getUser(),
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}
import { SWRConfig } from 'swr'
import { getUser } from './user' // サーバーサイド関数

export default function RootLayout({ children }) {
  return (
    <SWRConfig
      value={{
        fallback: {
          // ここでは getUser() を await しません
          // このデータを読み取るコンポーネントのみがサスペンドされます
          '/api/user': getUser(),
        },
      }}
    >
      {children}
    </SWRConfig>
  )
}

これはサーバーコンポーネントであるため、getUser() は安全にクッキー、ヘッダーを読み取ったり、データベースと通信したりできます。別の API ルートは必要ありません。<SWRConfig> 以下のクライアントコンポーネントは、同じキーで useSWR() を呼び出してユーザーデータを取得できます。useSWR を含むコンポーネントコードは、既存のクライアントフェッチソリューションから変更する必要はありません

'use client'

import useSWR from 'swr'

export function Profile() {
  const fetcher = (url) => fetch(url).then((res) => res.json())
  // 既知の SWR パターン
  const { data, error } = useSWR('/api/user', fetcher)

  return '...'
}
'use client'

import useSWR from 'swr'

export function Profile() {
  const fetcher = (url) => fetch(url).then((res) => res.json())
  // 既知の SWR パターン
  const { data, error } = useSWR('/api/user', fetcher)

  return '...'
}

fallback データはプリレンダリングされ、初期 HTML レスポンスに含まれ、その後 useSWR を使用して子コンポーネントですぐに読み取られます。SWR のポーリング、再検証、キャッシュはクライアントサイドでのみ実行されるため、SPA に依存するすべてのインタラクティビティが保持されます。

初期の fallback データは Next.js によって自動的に処理されるため、以前は dataundefined かどうかをチェックするために必要な条件付きロジックを削除できます。データが読み込み中の場合、最も近い <Suspense> 境界がサスペンドされます。

SWRRSCRSC + SWR
SSR データCross IconCheck IconCheck Icon
SSR 中のストリーミングCross IconCheck IconCheck Icon
リクエストの重複排除Check IconCheck IconCheck Icon
クライアント機能Check IconCross IconCheck Icon

React Query を使用した SPA

React Query を Next.js でクライアントとサーバーの両方で使用できます。これにより、厳密な SPA を構築できるだけでなく、Next.js のサーバー機能を React Query と組み合わせて利用できます。

詳細は React Query ドキュメント をご覧ください。

ブラウザのみでコンポーネントをレンダリングする

クライアントコンポーネントは next build 中にプリレンダリングされます。クライアントコンポーネントのプリレンダリングを無効にし、ブラウザ環境でのみ読み込みたい場合は next/dynamic を使用できます:

import dynamic from 'next/dynamic'

const ClientOnlyComponent = dynamic(() => import('./component'), {
  ssr: false,
})

これは windowdocument などのブラウザ API に依存するサードパーティライブラリに役立ちます。また、これらの API の存在をチェックする useEffect を追加し、存在しない場合はプリレンダリングされる null またはローディング状態を返すこともできます。

クライアントでの浅いルーティング

Create React AppVite のような厳密な SPA から移行する場合、URL 状態を更新するために浅いルーティングを行う既存のコードがあるかもしれません。これは、デフォルトの Next.js ファイルシステムルーティングを使用せずに、アプリケーション内のビュー間の手動遷移に役立ちます。

Next.js では、ネイティブの window.history.pushStatewindow.history.replaceState メソッドを使用して、ページをリロードせずにブラウザの履歴スタックを更新できます。

pushStatereplaceState の呼び出しは Next.js ルーターと統合され、usePathnameuseSearchParams と同期できます。

'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder: string) {
    const urlSearchParams = new URLSearchParams(searchParams.toString())
    urlSearchParams.set('sort', sortOrder)
    window.history.pushState(null, '', `?${urlSearchParams.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>昇順で並べ替え</button>
      <button onClick={() => updateSorting('desc')}>降順で並べ替え</button>
    </>
  )
}
'use client'

import { useSearchParams } from 'next/navigation'

export default function SortProducts() {
  const searchParams = useSearchParams()

  function updateSorting(sortOrder) {
    const urlSearchParams = new URLSearchParams(searchParams.toString())
    urlSearchParams.set('sort', sortOrder)
    window.history.pushState(null, '', `?${urlSearchParams.toString()}`)
  }

  return (
    <>
      <button onClick={() => updateSorting('asc')}>昇順で並べ替え</button>
      <button onClick={() => updateSorting('desc')}>降順で並べ替え</button>
    </>
  )
}

Next.js でのルーティングとナビゲーションの仕組みについて詳しく学べます。

クライアントコンポーネントでのサーバーアクションの使用

クライアントコンポーネントを使用しながらも、サーバーアクションを段階的に採用できます。これにより、API ルートを呼び出すためのボイラープレートコードを削除し、代わりに useActionState などの React 機能を使用してローディング状態やエラー状態を処理できます。

例えば、最初のサーバーアクションを作成します:

'use server'

export async function create() {}
'use server'

export async function create() {}

サーバーアクションをクライアントからインポートして使用でき、JavaScript 関数を呼び出すのと同様です。手動で API エンドポイントを作成する必要はありません:

'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>作成</button>
}
'use client'

import { create } from './actions'

export function Button() {
  return <button onClick={() => create()}>作成</button>
}

サーバーアクションを使用したデータの変更について詳しく学べます。

静的エクスポート(オプション)

Next.js は完全な静的サイトの生成もサポートしています。これには厳密な SPA に比べていくつかの利点があります:

  • 自動コード分割: 単一の index.html を配信する代わりに、Next.js はルートごとに HTML ファイルを生成するため、訪問者はクライアント JavaScript バンドルの待ち時間なしにより速くコンテンツを取得できます。
  • ユーザーエクスペリエンスの向上: すべてのルートに対して最小限のスケルトンを表示する代わりに、各ルートに対して完全にレンダリングされたページを取得します。ユーザーがクライアントサイドでナビゲートする場合、遷移は瞬時で SPA のようなままです。

静的エクスポートを有効にするには、設定を更新します:

next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'export',
}

export default nextConfig

next build を実行すると、Next.js はアプリケーションの HTML/CSS/JS アセットを含む out フォルダを作成します。

注: Next.js のサーバー機能は静的エクスポートではサポートされていません。詳細

既存プロジェクトの Next.js への移行

次のガイドに従って Next.js に段階的に移行できます:

すでに Pages Router で SPA を使用している場合は、App Router の段階的採用方法を学べます。