YOSHIMO
homegithubtwitter

Next.js × SPA

Next.js を使って SPA を作る。

yoshimo
7 February, 2025

はじめに

みなさんお久しぶりです!Web エンジニアの yoshimo(nakamoto)です。

今回は SPA とは何かと Next.js を使って SPA を作るときの要点を共有します。

SPA(Single-Page Application)

SPA はまず一つの HTML(index.html)と JavaScript(main.js)を返し、そのあとの処理はいずれも CSR(Client-side Rendering)で行われます。しかし、従来は React で SPA のアプリケーションを作るときに、大きく2つの問題がありました。1つ目は、全てのページの JavaScript を一気に読み込むととても重くなるという問題です(小規模なアプリなら問題なくとも、大規模なアプリだととても重くなります)。2つ目の問題はクライアントサイドのウォーターフォールです。つまり、リクエストがまずあって、その結果をもとにまたリクエストが行われるため、パフォーマンスが悪いです(まず上のコンポーネントがレンダリングされたら、次のコンポーネントがデータフェッチする)

Next.js(App Router)

上記の問題を解決するために React Router や Next.js などのフレームワークが重要になります。まず Next.js はルートごとに自動で JavaScript バンドルを分割し(チャンキングと呼ばれる)、複数の HTML エントリポイントを生成します。それによって、ユーザーは Web アプリを見るときに一つの HTML とその JavaScript を読み込むことになるため、1つ目の問題は解消します。

次に Next.js は データフェッチを親コンポーネント(or ルートレイアウト)で行なって Promise を返し、その値を use hook によってクライアントコンポーネントに展開することを推奨しています。データフェッチを親コンポーネント(or ルートレイアウト)にリフトアップすると、サーバーサイドで早めにデータフェッチを開始し、クライアントサイドに直ぐにレスポンスのストリーミングを開始できるということですね。

それによって、2つ目の問題(クライアントサイドのウォーターフォール)は解消されます。さらにクライアントとサーバー間のラウンドトリップも減りますし、サーバーは DB の近く(理想的には同じ場所)にあるので、パフォーマンスが大幅に向上します。

import { UserProvider } from './user-provider'
import { getUser } from './user' // some server-side function
 
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  let userPromise = getUser() // do NOT await
 
  return (
    <html lang="en">
      <body>
        <UserProvider userPromise={userPromise}>{children}</UserProvider>
      </body>
    </html>
  )
}

注)ルートレイアウトで Promise を取得するのに await は使いません。
(await を使ったらブロッキングが走ってしまうため)

'use client';
 
import { use, createContext, ReactNode } from 'react';
 
type User = any;
type UserContextType = {
  user: User;
};
 
const UserContext = createContext<UserContextType | null>(null);
 
export function UserProvider({
  children,
  userPromise,
}: {
  children: ReactNode;
  userPromise: Promise<User | null>;
}) {
  let initialUser = use(userPromise);
 
  return (
    <UserContext.Provider value={{ user: initialUser }}>
      {children}
    </UserContext.Provider>
  );
}
 
export function useUser(): UserContextType {
  let context = use(UserContext);
  if (context === null) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
}
'use client'
 
import { useUser } from './user-provider'
 
export function Profile() {
  const { user } = useUser()
 
  return '...'
}

補足)ちなみに Promise を使ってるコンポーネントは Suspense され、部分的にハイドレーションできる為、JavaScript のローディングが完了する前にストリーミング済み・プリレンダリング済みの HTML は見れます。

ただデータフェッチは、先述の方法よりも SWR によって SWRConfig の fallback(初期データ)を使った方がとても簡単に書けそうです(https://nextjs.org/docs/app/building-your-application/upgrading/single-page-applications#spas-with-swr

参照

https://nextjs.org/docs/app/building-your-application/upgrading/single-page-applications