※Next.js13.4でApp RouterがStableになったので新たに記事を公開しました。

Next.js 13の新機能であるappディレクトリは本記事公開時はbetaで現在も開発中ですがNext.js 13でプロジェクトを作成後にNext.jsの設定ファイルであるnext.config.jsでexperimentalとして設定を行うことで利用することができます。今後仕様が変わると思いますがappディレクトリの設定方法を中心にNext.js 13の機能説明を行っています。

Next.js 13.4でappディレクトリはStableとなり本番環境でも利用することができます。

appディレクトリの開発状況についてはロードマップから確認することができます。appディレクトリについてのドキュメントは公開されていますが通常のドキュメントとは別になっています。appディレクトリの本番環境での運用は推奨されていませんが、従来のpagesディレクトリを利用した場合のNext.js 13については本番環境(stable)で利用することができます。そのためcreate-next-appコマンドでプロジェクトを作成すると執筆時の最新版であるNext.js 13がインストールされます。

プロジェクトの作成

create-next-appコマンドを利用してプロジェクトの作成を行います。プロジェクト名はnextjs-13に設定していますが任意の名前をつけてください。JavaScriptとTypeScriptのどちらかを選択することができますが本文書ではTypeScriptを選択します。プロジェクト作成時にappディレクトリを利用するかどうかも聞かれます。


% npx create-next-app@latest
✔ What is your project named? … nextjs-13
✔ Would you like to use TypeScript with this project? … No / Yes
✔ Would you like to use ESLint with this project? … No / Yes
✔ Would you like to use `src/` directory with this project? … No / Yes
? Would you like to use experimental `app/` directory with this project? › No / ? Would you like to use experimental `app/` directory with this project? › No / ✔ Would you like to use experimental `app/` directory with this project? … No / Yes
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in /Users/mac/Desktop/nextjs-13.

Using npm.

Installing dependencies:
- react
- react-dom
- next
- @next/font
- typescript
- @types/react
- @types/node
- @types/react-dom
- eslint
- eslint-config-next


added 270 packages, and audited 271 packages in 6s

102 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Initializing project with template: app 

Initialized a git repository.

Success! Created nextjs-13 at /Users/mac/Desktop/nextjs-13

作成したnextjs-13ディレクトリに移動してpackage.jsonでnext.jsとreactのバージョンを確認しておきます。next.jsのバージョンは13.1.6, reactのバージョンは18.2.0です。


{
  "name": "nextjs-13",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@next/font": "13.1.6",
    "@types/node": "18.13.0",
    "@types/react": "18.0.28",
    "@types/react-dom": "18.0.11",
    "eslint": "8.34.0",
    "eslint-config-next": "13.1.6",
    "next": "13.1.6",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "typescript": "4.9.5"
  }
}

appディレクトリの有効化

※プロジェクト作成時にappディレクトリを選択している場合にはappディレクトリは有効化されているのでpagesディレクトリからappディレクトリへ変更する場合の参考として確認しておきます。

appディレクトリはオプションなのでnext.config.jsに追加設定を行う必要があります。experimentalプロパティを追加してappDirをtrueに設定します。


/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: {
    appDir: true,
  },
};

module.exports = nextConfig;

設定後npm run devコマンドを実行するとappディレクトリを有効化したという警告メッセージが表示されます。


 % npm run dev

> nextjs-13@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
warn  - You have enabled experimental feature (appDir) in next.config.js.
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.
info  - Thank you for testing `appDir` please leave your feedback at https://nextjs.link/app-feedback
info  - VS Code settings.json has been created for Next.js' automatic app types, this file can be added to .gitignore if desired
event - compiled client and server successfully in 2.4s (246 modules)

ブラウザからhttp://localhost:3000にアクセスすると”Welcom to Next.js!”が表示されますがこの内容はpageディレクトリindex.tsxファイルに記述した内容が表示されています。appディレクトリを有効化しただけではpagesディレクトリに影響はありません。

初期画面
初期画面

appディレクトリに作成するファイルの内容をブラウザ上に表示するためにプロジェクトフォルダの直下にappディレクトリを作成しその下にpage.tsxファイルを作成して以下を記述します。


export default function Home() {
  return <main>Hello Next.js 13</main>;
}

appディレクトリにpage.tsxファイルを作成後npm run devコマンドを実行するとappディレクトリのpage.tsxファイルとpagesディレクトリのindex.tsxファイルがconflictするためエラーが発生します。appディレクトリを利用するためにpagesディレクトリのindex.tsxファイルを削除する必要があります。pagesディレクトリの_app.tsxファイルも利用しないので削除します。


error - Conflicting app and page file was found, please remove the conflicting files to continue:
error -   "pages/index.tsx" - "app/page.tsx"
error - Error: Conflicting app and page file found: "app/page.tsx" and "pages/index.tsx". Please remove one to continue.

削除後に再度npm run devコマンドを実行すると”Your page app/page.tsx did not have a root layout. We created app/layout.tsx and app/head.tsx for you.”のメッセージが表示されappディレクトリにはlayout.tsxファイルとhead.tsxファイルが自動で作成されます。


Your page app/page.tsx did not have a root layout. We created app/layout.tsx and app/head.tsx for you.

ブラウザ上にはapp/page.tsxファイルで記述した内容が表示されます。

app/page.tsxファイルの内容が表示されます
app/page.tsxファイルの内容が表示されます

自動で作成されるlayout.tsxファイルの中身を確認するとpage.tsxファイルをラップするhtmlタグやbodyタグが設定されていることが確認できます。


export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <head />
      <body>{children}</body>
    </html>
  )
}

head.tsxファイルの中身を確認するとメタタグの設定が行えることがわかります。


export default function Head() {
  return (
    <>
      <title></title>
      <meta content="width=device-width, initial-scale=1" name="viewport" />
      <link rel="icon" href="/favicon.ico" />
    </>
  )
}

デフォルトではtitleタグが空白になっておりブラウザのタブにはlocalhost:3000が設定されています。titleにNext.js 13を設定してブラウザのタブにtitleが反映されるか確認します。


export default function Head() {
  return (
    <>
      <title>Next.js 13</title>
      <meta content="width=device-width, initial-scale=1" name="viewport" />
      <link rel="icon" href="/favicon.ico" />
    </>
  )
}

設定変更後ブラウザをリロードするとtitleタグに設定した内容が表示されることが確認できます。

head.tsxファイルによるtitleの更新
head.tsxファイルによるtitleの更新

appディレクトリの確認

※プロジェクト作成時にappディレクトリを選択した場合に参考にしてください。

Next.jsの設定ファイルであるnext.config.jsファイルを確認するとappDirがtrueになっていることが確認できます。


/** @type {import('next').NextConfig} */
const nextConfig = {
  experimental: {
    appDir: true,
  },
}

module.exports = nextConfig

npm run devコマンドを実行するとexperimental featureを有効にしているというメッセージが表示されます。


npm run dev

> nextjs-13@0.1.0 dev
> next dev

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
warn  - You have enabled experimental feature (appDir) in next.config.js.
warn  - Experimental features are not covered by semver, and may cause unexpected or broken application behavior. Use at your own risk.

info  - Thank you for testing `appDir` please leave your feedback at https://nextjs.link/app-feedback
event - compiled client and server successfully in 2.4s (246 modules)

ブラウザで確認するとNext.js13の初期画面が表示されます。

初期画面の表示
初期画面の表示

画面左上に”Get started by editing app/page.tsx”と表示されているのappディレクトリの中を確認するとglobals.css, head.tsx, layout.tsx, page.module.css, page.tsxファイルが作成されていることが確認できます。これらのファイルの中に開発サーバ起動時に表示されている内容が記述されています。

app/page.tsxファイルを下記のように更新します。


export default function Home() {
  return <main>Hello Next.js 13</main>;
}

ブラウザ上にはapp/page.tsxファイルで記述した内容が表示されます。

app/page.tsxファイルの内容が表示されます
app/page.tsxファイルの内容が表示されます

自動で作成されるlayout.tsxファイルの中身を確認するとpage.tsxファイルをラップするhtmlタグやbodyタグが設定されていることが確認できます。


import './globals.css'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      {/*
        <head /> will contain the components returned by the nearest parent
        head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head
      */}
      <head />
      <body>{children}</body>
    </html>
  )
}

head.tsxファイルの中身を確認するとメタタグの設定が行えることがわかります。


export default function Head() {
  return (
    <>
      <title>Create Next App</title>
      <meta content="width=device-width, initial-scale=1" name="viewport" />
      <meta name="description" content="Generated by create next app" />
      <link rel="icon" href="/favicon.ico" />
    </>
  )
}

デフォルトではtitleタグがCreate Next Appが設定されておりブラウザのタブには”Create Next App”が設定されています。titleにNext.js 13を設定してブラウザのタブにtitleが反映されるか確認します。


export default function Head() {
  return (
    <>
      <title>Next.js</title>
      <meta content="width=device-width, initial-scale=1" name="viewport" />
      <meta name="description" content="Generated by create next app" />
      <link rel="icon" href="/favicon.ico" />
    </>
  );
}

設定変更後ブラウザをリロードするとtitleタグに設定した内容が表示されることが確認できます。

head.tsxファイルによるtitleの更新
head.tsxファイルによるtitleの更新

Tailwind CSSの設定

スタイリングを適用する際にTailwind CSSを利用するためインストールと設定を行います。複雑なスタイルを利用していないのでTailwind CSSは必須ではありません。Tailwind CSSのドキュメントに記載されてい手順を参考にインストールと設定を行なっています。

Tailwind CSSのライブラリのインストールと設定ファイルの作成を行います。


 % npm install -D tailwindcss postcss autoprefixer
 % npx tailwindcss init -p

Created Tailwind CSS config file: tailwind.config.js
Created PostCSS config file: postcss.config.js

作成されるtailwind.config.jsファイルに以下を記述します。pagesディレクトリの他に動作確認をappディレクトリの追加を行なっています。


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./pages/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

appディレクトリのglobal.cssに以下の設定を行います。appディレクトリの下にglobal.cssがない場合は作成してください。


@tailwind base;
@tailwind components;
@tailwind utilities;

ページの追加

Next.jsではファイルベースルーティングによりファイルを作成することで自動でルーティングの設定が行われます。pagesディレクトリの場合には/aboutに対するページを作成したい場合にはabout.tsxファイルをpagesディレクトリの下に作成することで自動でルーティングが設定されていました。appディレクトリではaboutディレクトリを作成してその下にpage.tsxファイルを作成する必要があります。


const About = () => {
  return <h1>Aboutページ</h1>;
};

export default About;

作成後http://localhost:3000/aboutにアクセスするとpage.tsxファイルに記述した内容が表示されます。

aboutページ
aboutページ

プロジェクト作成時にappディレクトリを選択していない場合には下記の画面が表示されます。

Aboutページの内容を確認
Aboutページの内容を確認

about/page.tsxファイルの内容が表示されることが確認できましたがh1タグのデフォルトのスタイルが反映されており、Tailwind CSSが反映されていないことがわかります。Tailwind CSSを適用させるためにはstylesのglobal.cssをimportする必要があります。global.cssのimportはlayout.tsxファイルで行っておきます。


import './globals.css';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head />
      <body>{children}</body>
    </html>
  );
}

About.tsxもlayout.tsxのレイアウト設定が適用されているのでTailwind CSSが反映されh1タグのデフォルトのスタイルが解除されます。つまりすべてのページにappディレクトリ直下のlayout.tsxファイルのレイアウトが適用されることになります。

aboutページ
aboutページ

appディレクトリでのページの追加方法を理解することができました。

ヘッダーコンポーネントを追加

アプリケーション全体で共通のヘッダーコンポーネントを追加したい場合はヘッダーコンポーネントを作成してlayout.tsxファイルでimportすることですべてのページに適用されます。

appディレクトリにheader.tsxファイルを作成して下記のコードを記述します。


import Link from 'next/link';

const header = () => {
  return (
    <header>
      <nav className="p-2">
        <ul className="flex items-center space-x-2">
          <li>
            <img src="/vercel.svg" className="w-32" />
          </li>
          <li>
            <Link href="/">Home</Link>
          </li>
          <li>
            <Link href="/about">About</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
};

export default header;

ロゴ画像は/publicフォルダにあるvercel.svgファイルを利用します。imgタグではなくnext/imageのImageコンポーネントを利用することもできます。ページのリンクにはnext/linkのLinkコンポーネントを利用しています。Next.js 13ではLinkコンポーネントの中にaタグを設定する必要はありません。aタグを追加しなくてもブラウザ上ではaタグが利用されます。

作成したheader.tsxファイルをapp/layout.tsxファイルでimportします。


import '../styles/globals.css';
import Header from './header';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html>
      <head />
      <body>
        <Header />
        {children}
      </body>
    </html>
  );
}

ヘッダーのHomeまたはAboutのリンクをクリックするとスムーズにページ移動が行えることを確認してください。layout.tsxファイルでHeaderコンポーネントをimportしているので/(ルート), /aboutどちらのページにもヘッダーが表示されます。

ヘッダーを追加後のトップページ
ヘッダーを追加後のトップページ

Nested Layout

レイアウトのネスト化を確認するために新たにルーティング/testを追加するためにappディレクトリ直下にtestディレクトリを作成してその下にpage.tsxファイルを作成します。


const Test = () => {
  return <h1>Testページ</h1>;
};

export default Test;

ブラウザで/testにアクセスするとTestページの文字列が表示されます。

Testページの追加
Testページの追加

header.tsxファイルに/testへのリンクを設定します。


import Link from 'next/link';

const header = () => {
  return (
    <header>
      <nav className="p-2 h-12">
        <ul className="flex items-center space-x-2">
          <li>
            <img src="/vercel.svg" className="w-32" />
          </li>
          <li>
            <Link href="/">Home</Link>
          </li>
          <li>
            <Link href="/about">About</Link>
          </li>
          <li>
            <Link href="/test">Test</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
};

export default header;

レイアウトファイルのネスト化を行うためにtestディレクトリにlayout.tsxファイルを作成します。レイアウトにはサイドバーを追加しています。


export default function TestLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="h-screen flex">
      <div className="bg-gray-100 p-2 w-48">サイドバー</div>
      <div className="p-2">{children}</div>
    </div>
  );
}

ブラウザで/testにアクセスするとtestディレクトリのlayout.tsxファイルで設定したサイドバーとappディレクトリ直下のlayout.tsxファイルで設定したヘッダーが表示されていることが確認できます。このことからapp直下にあるlayout.tsxファイルとapp/testに作成したlayout.tsxファイルの両方が適用されていることが確認できます。app/test以下に作成するページについてはすべて2つのレイアウトが適用されることになります。このようにディレクトリ毎にレイアウトファイルを作成してネスト化することができます。

レイアウトが適用されていることを確認
レイアウトが適用されていることを確認

ヘッダーのメニューから/aboutページに移動するとサイドバーが表示されずappのlayout.tsxファイルのレイアウトのみ適用されていることが確認できます。

/aboutページの確認
/aboutページの確認

Server Component

appディレクトリに作成したファイルはデフォルトではすべてServer Componentでfetchなどの処理を記述するとファイル単位でサーバ側で処理が行われます。pageファイルだけではなくlayoutファイル、pageファイル、componentファイルでもサーバ側でfetch処理を行うことができます。

fetch関数によるデータ取得

Server Componentの動作確認を行うために新たにtestディレクトリにUsersList.tsxファイルを作成します。UsersListコンポーネントの中ではfetch関数を利用して無料で利用できるJSONPlaceHolderからユーザ一覧を取得しています。https://jsonplaceholder.typicode.com/usersにアクセスすると10名分のユーザ情報が取得できます。

ユーザ情報を取得後はmap関数を利用して展開しています。


type User = {
  id: number;
  name: string;
  email: string;
};

const getUsers = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return users;
};

const UsersList = async () => {
  const users = await getUsers();
  return (
    <>
      <h2 className="text-lg font-bold mt-4">ユーザ一覧</h2>
      <ul>
        {users && users.map((user) => <li key={user.id}>{user.name}</li>)}
      </ul>
    </>
  );
};

export default UsersList;

作成したUserListsコンポーネントはtest/page.tsxファイルでimportします。


import UsersList from './UsersList';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <UsersList />
    </>
  );
};

export default Test;

設定後ブラウザで/testにアクセスするとユーザ一覧が表示されます。

ユーザ一覧の表示
ユーザ一覧の表示

サーバ上での処理とクライアントに戻されるデータ

ユーザ一覧が表示されましたが表示されている内容を見ただけではサーバ側でデータを取得したのかクライアント側(ブラウザ側)でデータの取得したのかわからないのでconsole.logを利用して取得したusers情報を表示させます。


const getUsers = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  console.log(users);
  return users;
};

ブラウザのデベロッパーツールのコンソールにはユーザ情報は表示されませんがnpm run devコマンドを実行したターミナルに取得したユーザ情報が表示されます。このことからサーバ側で処理が行われていることがわかります。

さらにデベロッパーツールのネットワークタブで戻ってくるデータを確認します。直接testページにアクセスした時(/testでリロードもしくは/testをブラウザのURLに直接打ち込んで表示)に戻されるデータはHTMLであることがわかります。

アクセス時に戻されるデータ
アクセス時に戻されるデータ

サーバから戻されたデータは画面上だけではなくscriptタグの中にも文字列として保存されていることもわかります。

scriptタグに含まれるデータ
scriptタグに含まれるデータ

先ほどはtestページに対して直接アクセスを行いましたが今度はaboutページにアクセス後にtestページに移動した場合にどのような処理が行われるの確認します。Chromeのデベロッパーツールのネットワークタブではサーバから戻されるresponseの中身が見えなかったのでFirefoxのデベロッパーツールを利用しています。

aboutページにアクセスするとHTMLで戻されていることがわかります。この動作はtestページにアクセスした時と同じです。

サーバから戻されるデータ
サーバから戻されるデータ

ページのナビゲーションにあるTestのリンクをクリックしてtestページに移動するとサーバからのResponseにコンポーネントの情報が含まれていることがわかります。コンソールにはconsole.logでfetchで取得したユーザ情報を表示するようにしているためnpm run devコマンドを実行したコンソールには取得したユーザ一覧が表示されます。

サーバから戻されるデータ
サーバから戻されるデータ

ResponseのPayloadの部分の画像を大きくするとJ1の部分がサードバーtest/layout.tsxの内容のJ5の部分がtest/UsersList.tsxに記述している内容であることがわかります。aboutページにはサイドバーが存在しないのでaboutページからtestページに移動するとサイドバーコンポーネントの情報も取得しています。このコンポーネント情報を利用してReact Elementを作成して必要な箇所にコンポーネントが追加され、ページが更新されることが予想できます。

サーバから戻されるデータ
サーバから戻されるデータ

ページへのアクセス方法によらずfetch処理がサーバ側で行われること、最初にページにアクセスした場合にサーバから戻されるデータとページ移動でアクセスした場合では戻されるデータが異なることがわかりました。

デフォルトではfetchの結果をキャッシュするため一度testページにアクセスして他のページに移動して再度testページにアクセスしてもfetchの処理が行われないためターミナルにユーザ一覧が表示されることはありません。開発環境では、再度ブラウザをリロードするとキャッシュがクリアされfetchが実行されます。後ほど確認を行いますが本番環境ではビルドを行うとtestページはtestページに対応する静的ファイルtest.htmlファイルが作成されます。

Client Component

クライアント側でインタラクティブな処理を行いたい場合はClient Componentとしてコンポーネントを設定する必要があります。

Server ComponentとClient Componentの違い

Server ComponentとClient Componentの使い分けについてNext.jsのドキュメントに記載されているので確認しておきます。

Sever Component vs Client Component
Sever Component vs Client Component

相違的は複数存在しますが気になる項目のみピックアップしておきます。

  • Fetch dataについてはServer ComponentだけではなくClient Componentでも行うことができますが特別な理由がなければServer Componentでの利用を推奨すると記載されています。
  • アクセストークンやAPIキーなど外部に漏れてはいけない情報を利用する場合にはServer Componentで処理を行うことになります。
  • clickイベントやuseState, useEffect, ブラウザが持つAPIを利用する場合はServer Componentでは利用できないのでClient Componentを利用することになります。

Client Componentがどのような動作になるのか確認するためにclickイベントを持つカウンターコンポーネントを作成します。

カウンターコンポーネントの作成

testディレクトリにCounter.tsxファイルを作成します。count変数をuseStateを利用して定義し、ボタンをクリックするとsetCountでカウントの数字を1増やすというシンプルなコードです。


import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  const countUp = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <h2 className="text-lg font-bold mt-4">カウンター</h2>
      <div>Count: {count}</div>
      <button onClick={countUp} className="px-2 py-1 rounded-full bg-blue-300">
        +
      </button>
    </>
  );
};

export default Counter;

作成したCounterコンポーネントをtest/page.tsxファイルでimportします。


import UsersList from './UsersList';
import Counter from './Counter';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <Counter />
      <UsersList />
    </>
  );
};

export default Test;

ブラウザから/testにアクセスするとエラーメッセージが表示されます。デフォルトではuseStateを利用している場合はClient Componentでのみ動作し”use client”の設定が必要だということがメッセージとして表示されています。

useStateによるエラーの発生
useStateによるエラーの発生

エラーメッセージで表示されている通りcounter.tsxファイルの先頭に’use client’を追加します。’use client’を設定することによりClient Componentとなりクライアント(ブラウザ側)でclickイベントなどのインタラクティブな操作を行うことができます。


'use client';
import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  const countUp = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <h2 className="text-lg font-bold mt-4">カウンター</h2>
      <div>Count: {count}</div>
      <button onClick={countUp} className="px-2 py-1 rounded-full bg-blue-300">
        +
      </button>
    </>
  );
};

export default Counter;

再度ブラウザでアクセスするとカウンターが表示され、”+”ボタンをクリックするとカウンターの数字が増えていくことが確認できます。

カウンターの表示
カウンターの表示

サーバから戻されるデータを確認するとHTMLの中にカウンターの箇所も含まれていることがわかります。Client Componentという名前からすべてClient側で処理が行われると考えてしますかもしれませんがサーバ側でレンダリングされていることがわかります。

サーバから戻されるデータ
サーバから戻されるデータ

ブラウザ側でHTMLを受け取った後はHydrationによりインタラクティブなページへと更新されます。この部分はドキュメントでは”Client Components enable you to add client-side interactivity to your application. In Next.js, they are prerendered on the server and hydrated on the client.”と説明されているので説明通りに動作することが確認できました。

また、1つのページ内でSever Componentとclickイベントを持つClient Componentの設定が可能であることも確認できました。

Suspenseの設定

遅延処理の設定

Suspenseの動作確認を行うためtestページにアクセスした場合にサーバ側でのデータ取得に時間がかかる場合どのような動作になるのか確認します。PromiseとsetTimeoutを利用してUsersListコンポーネントのgetUsers関数に遅延処理を追加します。


const getUsers = async () => {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  console.log(users);
  return users;
};

遅延を設定後/testにアクセスしてブラウザのリロードを行うと設定した5秒間何も画面に変化がありません。次にヘッダーメニューのTestをクリックして/(ルート)から/testに移動した後はどのような画面表示になるか確認します。5秒間はクリック前の/(ルート)の画面が表示されたままの状態となります。

ページに変化がない
ページに変化がない

5秒経過するとサイドバーを含めたtestページが表示されます。Server Componentの処理が完了するまでページが表示されないことがわかります。

ユーザデータを取得後の画面
5秒経過後の画面

Suspenseの設定

ここでSuspenseを利用して非同期処理を行なっているUserListsコンポーネントをreactからimportしたSuspenseコンポーネントでラップします。fallback属性にはUserListsコンポーネントで非同期処理が実行されている間に表示させるコンテンツを設定します。


import UsersList from './UsersList';
import Counter from './Counter';
import { Suspense } from 'react';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <Counter />
      <Suspense fallback={<p className="mt-4">ユーザデータ Loading...</p>}>
        <UsersList />
      </Suspense>
    </>
  );
};

export default Test;

ブラウザをリロードして/からヘッダーメニューのTestのリンクを利用して/testへ移動します。

クリックした直後、先ほどは画面に変化はありませんでしたがSuspenseを追加後はサイドバーやカウンターなど表示され、UserListsコンポーネントが表示される場所にはfallbackに設定した”ユーザデータ Loading…”の文字列が表示されます。

非同期のgetPosts実行中の画面
非同期のgetPosts実行中の画面

5秒経過後にgetUsers関数でデータの取得が完了するとfallbackに設定していた文字列が消えユーザ一覧が表示されます。Suspenseを利用することで非同期を行うコンポーネント以外の内容は非同期処理が完了することを待たず即座に表示されることがわかります。

ユーザデータを取得後の画面
ユーザデータを取得後の画面

デベロッパーツールのネットワークタブを見ながら動作を確認します。testページにアクセスするとブラウザのタブのローディングが回った状態でネットワークタブは下記のような状態になっています。

アクセス直後のネットワークの状態
アクセス直後のネットワークの状態

5秒経過するとhtmlがダウンロードされたことがわかります。

ユーザ一覧が表示後
ユーザ一覧が表示後

Responseを見ると”ユーザデータ Loading…”の文字が含まれたHTMLを確認することができます。

受け取ったHTMLの中身
受け取ったHTMLの中身

HTMLのプレビューの中にユーザ一覧は含まれていませんがrawデータを見るとユーザ情報が含まれていることが確認できます。

ユーザ情報の確認
ユーザ情報の確認

ユーザデータの取得中でもカウンターは動作するため”+”ボタンをクリックするとカウンターの数字はアップします。

データ取得中にカウンターをアップ
データ取得中にカウンターをアップ

エラーの処理

fetch関数のデータ取得でエラーが発生した場合の動作確認を行います。getUsers関数の中のfetch関数で指定するURLを存在しないURLに変更します(ここではusersをuserにしています)。response.okがfalseでデータの取得に失敗した場合にErrorオブジェクトをthrowさせます。


const getUsers = async () =< {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('https://jsonplaceholder.typicode.com/user');
  if (!response.ok) throw new Error('Something went wrong');
  const users: User[] = await response.json();
  console.log(users);
  return users;
};

ブラウザから/testにアクセスするとエラー画面が表示されます。右上に”X”ボタンが表示されているのでクリックします。

エラー画面の表示
エラー画面の表示

画面には何も表示されませんが画面左下にエラー情報が表示されます。クリックすると先ほどのエラー画面が表示されます。”X”をクリックするとエラー情報が画面から消え画面は真っ白になります。

エラー情報の表示
エラー情報の表示

エラー処理はtestディレクトリにerror.tsxファイルを作成することで対応することができます。

error.tsxの中身はNext.jsのドキュメントに記載されているコードをコピー&ペーストしています。


'use client';

import { useEffect } from 'react';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () =< void;
}) {
  useEffect(() =< {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    >div<
      >p<Something went wrong!>/p<
      >button onClick={() =< reset()}<Reset error boundary>/button<
    >/div<
  );
}

このファイルを作成後再度ブラウザで/testにアクセスすると先ほどと同じ画面が表示されます。

エラー画面の表示
エラー画面の表示

しかし”X”ボタンをクリックすると画面が真っ白ではなくerror.tsxに記述した内容が表示されます。本番環境ではこの画面が即座に表示されます。

error.tsxの内容が表示
error.tsxの内容が表示

Suspenseを設定した場合にラップしているコンポーネントでエラーが発生した場合の処理を確認することができました。

存在しないURLの設定のままビルドを行うとビルドに失敗します。デフォルトではビルド時にStaticな静的ファイルを作成すためにデータを取得できず静的ファイルが作成できないためです。

複数コンポーネントでのSuspenseの設定

testページの中にはSever ComponentとClient Componentの2つのコンポーネントが含まれていましたがさらにもう一つ非同期処理を含むServer Componentを追加します。

testディレクトリにPostsList.tsxファイルを作成します。UsersListコンポーネントの内容とほぼ同じでアクセスする場所がusersからpostsに変更しています。遅延時間もUsersListよりも少し長くしています。


type Post = {
  id: number;
  title: string;
};

const getPosts = async () => {
  await new Promise((resolve) => setTimeout(resolve, 8000));
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  if (!response.ok) throw new Error('Something went wrong');
  const posts: Post[] = await response.json();
  return posts;
};

const PostsList = async () => {
  const posts = await getPosts();
  return (
    <>
      <h2 className="text-lg font-bold mt-4">記事一覧</h2>
      <ul>
        {posts && posts.map((post) => <li key={post.id}>{post.title}</li>)}
      </ul>
    </>
  );
};

export default PostsList;

PostsListコンポーネントの作成が完了したらtest/page.tsxファイルでimportを行います。PostsListもSuspenseコンポーネントでラップしてfallbackにはUserListsをラップするSuspenseとは異なるコンテンツを設定します。


import UsersList from './UsersList';
import PostsList from './PostsList';
import Counter from './Counter';
import { Suspense } from 'react';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <Counter />
      <Suspense fallback={<p className="mt-4">ユーザデータ Loading...</p>}>
        <UsersList />
      </Suspense>
      <Suspense fallback={<p className="mt-4">記事データ Loading...</p>}>
        <PostsList />
      </Suspense>
    </>
  );
};

export default Test;

testページにアクセスすると最初はどちらもデータ取得中なのでfallbackで設定したコンテンツが表示されます。先ほど確認した通りサイドバーやカウンターは非同期処理が完了しているかに影響を受けず表示されます。

データ取得中
データ取得中

ユーザデータの方が遅延時間を短く設定しているので先に表示されます。記事データはまだ取得中なので”記事データ Loading…”が表示されています。

ユーザデータのみ表示
ユーザデータのみ表示

記事データの取得が完了すると記事一覧が表示されます。

記事データの取得後
記事データの取得後

このようにSuspenseはServer Component毎に設定を行うことができそれぞれ独立して処理が行われることがわかります。

Suspenseコンポーネントの中に2つのServer Componentをラップした場合の動作も確認します。


import UsersList from './UsersList';
import PostsList from './PostsList';
import Counter from './Counter';
import { Suspense } from 'react';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <Counter />
      <Suspense fallback={<p className="mt-4">ユーザ/記事データ Loading...</p>}>
        <UsersList />
        <PostsList />
      </Suspense>
      {/* <Suspense fallback={<p className="mt-4">記事データ Loading...</p>}>
      </Suspense> */}
    </>
  );
};

export default Test;

アクセス直後はfallbackで設定したコンテンツが表示されます。

データの取得中
データの取得中

両方のコンポーネントでデータの取得が完了すると一度に取得したデータが表示されます。先に取得したデータが表示されるということはありません。

ユーザ/記事データの取得後
ユーザ/記事データの取得後

1つのページ内で複数のServer Componentを追加することができそれぞれにSuspenseを設定できることがわかりました。

Data Fetching

Server Componentで実行するData Fetchingについてさらに確認していきます。

新たにルーティング/userを追加するためにappディレクトリにuserディレクトリを作成しその中にpage.tsxファイルを作成します。

page.tsxファイルもServer Componentなのでデフォルトではサーバ側でfetch処理が行われます。


type User = {
  id: number;
  name: string;
  email: string;
};

const getUsers = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return users;
};

const Users = async () => {
  const users = await getUsers();
  return (
    <main className="p-2">
      <h1 className="text-lg font-bold">Userページ</h1>
      <ul>
        {users && users.map((user) => <li key={user.id}>{user.name}</li>)}
      </ul>
    </main>
  );
};

export default Users;

appディレクトリのheader.tsxファイルに/userのリンクを追加しておきます。


import Link from 'next/link';

const header = () => {
  return (
    <header>
      <nav className="p-2 h-12">
        <ul className="flex items-center space-x-2">
          <li>
            <img src="/vercel.svg" className="w-32" />
          </li>
          <li>
            <Link href="/">Home</Link>
          </li>
          <li>
            <Link href="/about">About</Link>
          </li>
          <li>
            <Link href="/test">Test</Link>
          </li>
          <li>
            <Link href="/user">User</Link>
          </li>
        </ul>
      </nav>
    </header>
  );
};

export default header;

ブラウザから/userにアクセスするとユーザ一覧が表示されます。

ユーザ一覧の表示
ユーザ一覧の表示

ビルドの実行

Next.jsでは本番環境用にビルドを行うと構成する各ページがランタイム時にサーバサイド(Server Sider Rendring)で処理を行うのかビルド時に静的なファイルを作成して静的なファイルとしてクライアントに送信するのか確認することができます。

npm run buildコマンドでビルドを行うことができます。しかし実行するとTypeScriptのエラーが発生します。test/page.tsxでimportしたUsersListに関するエラーです。


 % npm run build
 //略
Type error: 'UsersList' cannot be used as a JSX component.
  Its return type 'Promise<Element>' is not a valid JSX element.
    Type 'Promise<Element>' is missing the following properties from type 'ReactElement<any, any<': type, props, key
//略

エラーを回避するために@ts-expect-error Sever Componentを追加します。一時的な対処方法なのですでに解消している場合はエラーが発生することなくビルドが完了します。


import UsersList from './UsersList';
import PostsList from './PostsList';
import Counter from './Counter';
import { Suspense } from 'react';
const Test = () => {
  return (
    <>
      <h1 className="text-xl font-bold">Testページ</h1>
      <Counter />
      <Suspense fallback={<p className="mt-4">ユーザデータ Loading...</p>}>
        {/* @ts-expect-error Server Component */}
        <UsersList />
      </Suspense>
      <Suspense fallback={<p className="mt-4">記事データ Loading...</p>}>
        {/* @ts-expect-error Server Component */}
        <PostsList />
      </Suspense>
    </>
  );
};

export default Test;
本エラーについてはドキュメントに説明が記載されています。Warning: You can use async/await in layouts and pages, which are Server Components. Using async/await inside other components, with TypeScript, can cause errors from the response type from JSX. We are working with the TypeScript team to resolve this upstream. As a temporary workaround, you can use {/* @ts-expect-error Server Component */} to disable type checking for the component.

対応後再度ビルドを実行すると/, /about, /test, /userはすべて静的なファイルとして作成されていることが確認できます。URLの横に○が表示されメッセージの下部を見ると “(Static) automatically rendered as static HTML”と表示されています。


 % npm run build

//略

Route (app)                                Size     First Load JS
┌ ○ /                                      0 B                0 B
├ ○ /about                                 164 B          69.4 kB
├ ○ /test                                  1.04 kB        70.3 kB
└ ○ /user                                  163 B          69.4 kB
+ First Load JS shared by all              69.2 kB
  ├ chunks/17-9be995ac8a45634e.js          66.4 kB
  ├ chunks/main-app-f7991bdf5a7d528d.js    253 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

Route (pages)                              Size     First Load JS
┌ ○ /404                                   210 B          87.1 kB
└ λ /api/hello                             0 B            86.9 kB
+ First Load JS shared by all              86.9 kB
  ├ chunks/main-1a049c33e58f2c13.js        84.1 kB
  ├ chunks/pages/_app-9f5490aa3d56632f.js  242 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)

ビルドが完了したファイルはプロジェクトディレクトリの.next/server/appの下に保存されているのでふフォルダを確認するとindex.html, test.html, user.htmlなどのファイルを確認することができます。

user.htmlファイルの中身を確認するとユーザ情報を含んだHTMLであることがわかります。

user.htmlファイルの中身
user.htmlファイルの中身

Client ComponentのCounter.tsxの部分もtest.htmlを見ると確認することができます。

test.htmlのConter.tsxの箇所
test.htmlのConter.tsxの箇所

npm run buildでビルド完了後はnpm startコマンドを実行してProduction Modeとして起動します。

ビルド時のメッセージ通りこれまで作成したページはすべてfetch関数の処理が完了してデータが取得済みの静的なファイルとして作成されているのでアクセスするとすぐに表示されます。

どのページにアクセスしてもナビゲーションですべてのページのリンクを貼っているのでprefecthによりすべてのページの情報がダウンロードされることが確認できます。

prefecthによるページデータのダウンロード
prefecthによるページデータのダウンロード

ダウンロードされたデータのPreviewに表示されている内容はビルド時に.next/server/appにhtmlファイルと一緒に保存されている拡張子の.rscファイルの中身と同じです。J8, J9を見るとh2タグやclassNameやchildrenなどコンポーネントに関する情報であることがわかります。リンクでページを移動する際には取得したデータの中にはHTMLではなくReact Elementを作成するために必要な情報が記述されているのでこの情報を利用してページ内の必要な箇所にコンポーネントを追加しページの更新が行わることが予想できます。

Dynamic Route

ユーザ一覧に表示された名前をクリックすると各ユーザの個別ページが表示できるようにリンクの設定を行います。


import Link from 'next/link';

type User = {
  id: number;
  name: string;
  email: string;
};

const getUsers = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return users;
};

const Users = async () => {
  const users = await getUsers();
  return (
    <main className="p-2">
      <h1 className="text-lg font-bold">Userページ</h1>
      <ul>
        {users &&
          users.map((user) => (
            <li key={user.id}>
              <Link href={`/user/${user.id}`}>{user.name}</Link>
            </li>
          ))}
      </ul>
    </main>
  );
};

export default Users;

userページにアクセスするとユーザ一覧が表示されます。

ユーザ一覧の表示
ユーザ一覧の表示

ブラウザ上には変化がありませんがデベロッパーツールのコンソールを見ると404 Not Foundエラーが発生しています。

404 Not Foundエラー
404 Not Foundエラー

Linkコンポーネントはデフォルトではprefetchを行うためまだ存在しない詳細ページへのアクセスを行い404 Not Foundエラーが発生しています。

propsのprefetchをfalseすることでprefetchを停止することができます。


<Link href={`/user/${user.id}`} prefetch={false}>

ユーザの詳細ページはuser/1, user/2のユーザのidによってアクセスするURLが異なります。動的にURLが変わるためDynamic Routeと呼ばれます。idが異なる場合でもページを表示するためには作成するディレクトリ名に[]をつける必要があります。ここではidを利用しているためディレクトリ名を[id]と設定します。

[id]ディレクトリの下にpage.tsxファイルを作成して以下を記述します。


const User = () => {
  return (
    <div className="p-2">
      <h1 className="font-bold text-lg">User詳細ページ</h1>
    </div>
  );
};

export default User;

[id]/page.tsxファイル作成後、ユーザ名をクリックすると下記の内容が表示されます。ユーザ毎にURLは異なりますが表示させる内容は/user/1でも/user/8でもすべて同じです。

ユーザ詳細ページ
ユーザ詳細ページ

URLに含まれるidはpropsのparamsに含まれているのでparamsからidを取り出します。取り出したidをブラウザ上に表示させています。


type Props = { params: { id: string } };

const User = ({ params: { id } }: Props) => {
  return (
    <div className="p-2">
      <h1 className="font-bold text-lg">User詳細ページ {id}</h1>
    </div>
  );
};

export default User;

ユーザ一覧のユーザ名をクリックするとユーザによって表示されるidが異なることが確認できます。

ユーザのidをブラウザに表示
ユーザのidをブラウザに表示

取り出したidを使ってJSONPlaceHolderにアクセスを行いユーザ情報の取得を行います。getUser関数を追加していますがこれまで利用してきたgetUsers関数と処理内容はほとんど同じです。getUserではidを利用してJSONPlaceHolderでユーザ毎の情報を取得しています。


type Props = { params: { id: string } };

type User = {
  id: number;
  name: string;
  email: string;
};

const getUser = async (id: string) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );
  const user: User = await response.json();

  return user;
};

const User = async ({ params: { id } }: Props) => {
  const user = await getUser(id);
  return (
    <div className="p-2">
      <h1 className="font-bold text-lg">User詳細ページ {id}</h1>
      <div>
        <div>名前: {user.name}</div>
        <div>メールアドレス: {user.email}</div>
      </div>
    </div>
  );
};

export default User;

ファイルを更新後はユーザの詳細情報が表示されます。

ユーザの詳細情報
ユーザの詳細情報

/user/1, /user/2,…, /user/10にアクセスした場合は問題なくページが表示されます。次は存在しない/user/11にアクセスを行なった場合の動作を確認します。

Not Foundの設定

ユーザ一覧にはidが11を持つユーザは存在しないので手動でブラウザのURLに/user/11を設定してアクセスを行います。しばらくすると表示されるページにはユーザの情報が含まれていないだけでエラーは表示されません。

ページが存在しないユーザidにアクセスがあった場合には404ページを表示させるためにnext/navigationのNotFound関数を利用します。


import { notFound } from 'next/navigation';

type Props = { params: { id: string } };

type User = {
  id: number;
  name: string;
  email: string;
};

const getUser = async (id: string) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );
  const user: User = await response.json();

  return user;
};

const User = async ({ params: { id } }: Props) => {
  const user = await getUser(id);
  if (!user.id) {
    notFound();
  }
  return (
    <div className="p-2">
      <h1 className="font-bold text-lg">User詳細ページ {id}</h1>
      <div>
        <div>名前: {user.name}</div>
        <div>メールアドレス: {user.email}</div>
      </div>
    </div>
  );
};

export default User;

処理を追加後はブラウザ上にはNotFound関数により404 NotFoundページが表示されます。NotFound関数によりNEXT_NOT_FOUNDエラーがthrowされています。

404エラーページの表示
404エラーページの表示

同じディレクトリの中にnot-found.tsxファイルを作成することでnot-found.tsxファイルに記述した内容を表示させることができます。


export default function NotFound() {
  return <p className="p-2">ページは存在しません。</p>;
}

not-found.tsxファイルを作成後存在しないユーザidを持つページにアクセスするとnot-found.tsxファイルに記述した内容が表示されます。存在しないユーザidのURLにアクセスがあった場合に対応できるようになりました。

not-found.tsxファイルの内容が表示される
not-found.tsxファイルの内容が表示される

loading.tsx

Suspenseを利用したい場合はServer ComponentをSuspenseコンポーネントでラップしましたがloading.tsxファイルを作成することでもサーバ側での非同期によるデータ取得中にローディングを表示させることができます。

[id]ディレクトリにloading.tsxファイルを作成します。


export default function Loading() {
  return <p className="p-2">Loading...</p>;
}

ファイルを保存しただけで設定は完了です。ローディングの画面が表示されることを確認するためにpage.tsxファイルのgetPost関数に遅延の処理を追加します。


const getUser = async (id: string) => {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`
  );
  const user: User = await response.json();

  return user;
};

/user/3にアクセスするとブラウザ上にはloading.tsxで設定した”Loading…”の文字列が表示されます。

データ取得中のローディングの表示
データ取得中のローディングの表示

5秒経過後データの取得が完了するとユーザの詳細情報が表示されます。loading.tsxファイルを作成するだけで簡単にSuspenseの設定を行うことができます。

ユーザの詳細情報
ユーザの詳細情報

ビルドの実行

先ほどビルドを実行した際はすべてのページがStaticな静的なページとして作成されました。Dymaic Routingを行なった場合にはどのようなページとして作成されるのか確認するためnpm run buildコマンドを実行してビルドを行います。/user/[id]を確認するとStaticではなくServerになっていることが確認できます。ランタイム時つまりブラウザからアクセスがあった時にサーバ側でデータ取得の処理が行われてデータ取得後にブラウザに戻されます。


 % npm run build

//略

Route (app)                                Size     First Load JS
┌ ○ /                                      0 B                0 B
├ ○ /about                                 166 B          69.4 kB
├ ○ /test                                  1.07 kB        91.2 kB
├ ○ /user                                  212 B          90.3 kB
└ λ /user/[id]                             218 B          69.4 kB
+ First Load JS shared by all              69.2 kB
  ├ chunks/17-9be995ac8a45634e.js          66.4 kB
  ├ chunks/main-app-f7991bdf5a7d528d.js    253 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

Route (pages)                              Size     First Load JS
┌ ○ /404                                   210 B          87.1 kB
└ λ /api/hello                             0 B            86.9 kB
+ First Load JS shared by all              86.9 kB
  ├ chunks/main-1a049c33e58f2c13.js        84.1 kB
  ├ chunks/pages/_app-9f5490aa3d56632f.js  242 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)

npm startコマンドを実行して動作確認を行うと/user/[id]にアクセスすると”loading…”が表示されアクセスする度にサーバ側で処理が行われていることがわかります。

ユーザの詳細情報は更新がないので静的なファイルとして扱っても問題がありません。Dynamic routeで設定されたページをStaticな静的なファイルとして作成したい場合にはどうすればいいのでしょうか。

generateStaticParams(SSGの設定)

Dynamic Routeを利用している場合に存在するページの一覧を定義することでビルド時に静的ファイルを作成してくれます。pageディレクトリで利用していたgetStaticPathsと同じような設定を行います。

generateStaticParams関数をuser/[id]/page.tsxファイルの最下部に追加します。JSONPlaceHolderでユーザの一覧を取得して取得したユーザ情報の中からURLを構成するidすべてを取り出して配列で戻しています。


export async function generateStaticParams() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();

  return users.map((user) => ({
    id: user.id.toString(),
  }));
}

追加後npm run buildコマンドを実行します。/user/[id]を確認するとSSG(Static Site Generation)として作成されていることが確認できます。/user/[id]の横には●が表示されています。ビルドのメッセージを見ると●はSSGだと説明があります。


  % npm run build

//略

Route (app)                                Size     First Load JS
┌ ○ /                                      0 B                0 B
├ ○ /about                                 168 B          69.4 kB
├ ○ /test                                  1.07 kB        91.2 kB
├ ○ /user                                  212 B          90.3 kB
└ ● /user/[id]                             218 B          69.4 kB
    ├ /user/1
    ├ /user/2
    ├ /user/3
    └ [+7 more paths]
+ First Load JS shared by all              69.2 kB
  ├ chunks/17-9be995ac8a45634e.js          66.4 kB
  ├ chunks/main-app-f7991bdf5a7d528d.js    253 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

Route (pages)                              Size     First Load JS
┌ ○ /404                                   210 B          87.1 kB
└ λ /api/hello                             0 B            86.9 kB
+ First Load JS shared by all              86.9 kB
  ├ chunks/main-1a049c33e58f2c13.js        84.1 kB
  ├ chunks/pages/_app-9f5490aa3d56632f.js  242 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

実際の動きを確認するためにnpm startコマンドを実行します。ユーザ一覧からユーザ名をクリックするとこれまでのように”loading…”の文字列が表示されることもなく即座にページが表示されます。

Dynamic Routingを利用している場合でもgenerateStaticParams関数を利用することで静的なファイルとしてページを作成できることがわかりました。

generateStaticParams関数を利用して静的に作成するファイルの数を指定した場合の動作確認を行います。ユーザ情報を取得すると10名分の情報を取得することができますがgenerateStaticParams関数を利用して3名分の静的ファイルのみ作成を行います。sliceメソッドを利用して3名分のidに制限しています。


export async function generateStaticParams() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();

  return users.slice(0, 3).map((user) => ({
    id: user.id.toString(),
  }));
}

npm run buildコマンドを利用してビルドを行います。generateStaticParams関数の設定通りビルド時には3名分のページしか作成されていません。


 % npm run build

//略

Route (app)                                Size     First Load JS
┌ ○ /                                      0 B                0 B
├ ○ /about                                 167 B          69.4 kB
├ ○ /test                                  1.07 kB        91.2 kB
├ ○ /user                                  212 B          90.3 kB
└ ● /user/[id]                             218 B          69.4 kB
    ├ /user/1
    ├ /user/2
    └ /user/3
+ First Load JS shared by all              69.2 kB
  ├ chunks/17-9be995ac8a45634e.js          66.4 kB
  ├ chunks/main-app-f7991bdf5a7d528d.js    253 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

Route (pages)                              Size     First Load JS
┌ ○ /404                                   210 B          87.1 kB
└ λ /api/hello                             0 B            86.9 kB
+ First Load JS shared by all              86.9 kB
  ├ chunks/main-1a049c33e58f2c13.js        84.1 kB
  ├ chunks/pages/_app-9f5490aa3d56632f.js  242 B
  └ chunks/webpack-827257e3b58da21b.js     2.58 kB

λ  (Server)  server-side renders at runtime (uses getInitialProps or getServerSideProps)
○  (Static)  automatically rendered as static HTML (uses no initial props)
●  (SSG)     automatically generated as static HTML + JSON (uses getStaticProps)

npm startコマンドで起動して/(ルート)にアクセスしその後URLに直接/user/10を設定してアクセスするとユーザ10の情報が表示されます。静的に作成されるファイルはプロジェクトディレクトリの.next/server/usersディレクトリの中に作成されるので1.html,2.html,3.htmlだけではなくnpm startコマンドで起動後にアクセスした10.htmlファイルが作成されていることが確認できます。このようにビルド時に作成されるだけではなくその後アクセスを行いページが存在する場合は新たに静的ファイルを作成してくれることがわかりました。

デフォルトではLinkコンポーネントにprefetchが設定されているので/userにアクセスするユーザ一覧が表示されるため1.htmlから10.htmlまでの静的ファイルが作成されます。Linkコンポーネントのprefetchをfalseに設定すると/userにアクセスしても4.htmlから10.htmlファイルはアクセスするまで作成されることはありません。

Revalidating Data(ISR)

ビルド時に静的なファイルで作成された後もページの内容を更新する場合があります。ページの更新を行なった際に再度すべてのページをリビルドする必要なく更新されたページの内容をクライアントに戻すことができます。それがRevalidating Dataです。pagesディレクトリではIncremental Static Regeneration(ISR)と呼ばれていました。

fetch関数にnextオプションを設定することでRevalidating Dataを行うことができます。getUser関数のfetch関数にnextオプションを追加します。revalidateプロパティに60を設定します。最初のリクエストが行われてから60秒過ぎてアクセスがあった場合に静的ファイルの更新を行います。


const getUser = async (id: string) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/users/${id}`,
    {
      next: {
        revalidate: 60,
      },
    }
  );
  const user: User = await response.json();

  return user;
};

60秒時間が経過してアクセスするとページが更新されるのか確認するためにMath.randam関数を利用して乱数を表示させておきます。


const User = async ({ params: { id } }: Props) => {
  const user = await getUser(id);
  if (!user.id) {
    notFound();
  }
  return (
    <div className="p-2">
      <h1 className="font-bold text-lg">User詳細ページ {id}</h1>
      <div>
        <div>名前: {user.name}</div>
        <div>メールアドレス: {user.email}</div>
        <div>{Math.random()}</div>
      </div>
    </div>
  );
};

設定後npm run buildコマンドでビルドを行います。ビルド後npm startコマンドを実行します。

/user/1にアクセスすると乱数が表示されます。複数回アクセスしても表示されている数字は変わりません。

ユーザの詳細ページに乱数表示
ユーザの詳細ページに乱数表示

60秒後の経過後にアクセスをするとその時のアクセスでは同じ内容が表示されます(バックグランドでデータの再取得が行われページが更新される)がその次のリクエストでは更新された乱数が表示されます。

nextオプションでrevalidateを設定していない場合はビルド時に作成されたデータがそのまま表示されるので一定時間を経過しても乱数が更新されることはありません。

appディレクテリの仕様についてはこれから変更が行われると思いますが基本的な動作確認を行うことができました。