Next.jsは現在最も人気のあるReactベースのフルスタックのJavaScriptフレームワークです。バージョンがアップする毎に新しい機能が次々に追加されNext.js13からServer ComponentsなどReactの最新機能を利用したApp Routerが登場しました。App Routerはファイル名でルーティングを設定していた既存のPage Routerとは全く異なる機能で設定方法も一から学び直す必要があります。新たにプロジェクトを作成するのであればApp Routerを利用することが推奨されていますが同時に両方の機能を利用することも可能です。

次々に新しい機能が追加される反面、ネット上に公開されている記事もすぐにOutDatedなものになっています。この文書もすぐにOutdatedなものになってしまうと思いますが現在(2023年5月)の最新バージョン13.4のドキュメントを参考に実際にNext.js13を利用しながら基本的な機能について説明を行っています。2024年3月の最新バージョンは14.1.4です。

目次

プロジェクトの作成

実際に公式ドキュメントを参考に手を動かしながら説明を進めていくため最初にNext.jsのプロジェクトの作成を行います。プロジェクト名は任意の名前をつけることができるのでここではnext-js-13としています。npx create-next-app@latestコマンドを実行するとTypeScript, ESLintをプロジェクトで利用するかどうか聞かれますがすべてデフォルトの値を選択しています。 App Routerを利用するかどうかも選択することができますがrecommededと表示されているため新しくプロジェクトを作成する場合にApp Routerを利用することが推奨されています。


 % npx create-next-app@latest
Need to install the following packages:
  create-next-app@13.4.1
Ok to proceed? (y) y
✔ What is your project named? … next-js-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 Tailwind CSS with this project? … No / Yes
✔ Would you like to use `src/` directory with this project? … No / Yes
✔ Use App Router (recommended)? … No / Yes
✔ Would you like to customize the default import alias? … No / Yes
Creating a new Next.js app in /Users/mac/Desktop/next-js-13.

Using npm.

Initializing project with template: app-tw 


Installing dependencies:
- react
- react-dom
- next
- typescript
- @types/react
- @types/node
- @types/react-dom
- tailwindcss
- postcss
- autoprefixer
- eslint
- eslint-config-next


added 352 packages, and audited 353 packages in 32s

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

found 0 vulnerabilities
Initialized a git repository.

プロジェクトの作成が完了後、プロジェクトフォルダnext-js-13に移動してpackage.jsonファイルでインストールしたパッケージのバージョンを確認しておきます。nextパッケージのバージョンが13.4以上であることを確認しておきます。


{
  "name": "next-js-13",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@types/node": "20.1.1",
    "@types/react": "18.2.6",
    "@types/react-dom": "18.2.4",
    "autoprefixer": "10.4.14",
    "eslint": "8.40.0",
    "eslint-config-next": "13.4.1",
    "next": "13.4.1",
    "postcss": "8.4.23",
    "react": "18.2.0",
    "react-dom": "18.2.0",
    "tailwindcss": "3.3.2",
    "typescript": "5.0.4"
  }
}

package.jsonファイルを確認後、npm run devコマンドを実行して開発サーバを起動してブラウザからアクセスを行い、初期ページが表示されることを確認します。初期画面に表示されている内容はappディレクトリの直下に保存されているpage.tsxファイルに記述されています。

トップページの表示
トップページの表示

Routingの設定

これまで利用してきたファイルシステムベースのルーティングはPages Routerと呼ばれNext.jsからはApp Routerという新機能が登場しました(Next.js 13.4からStable)。Pages RouterとApp Routerでは設定方法が異なります。どちらの機能も利用できるためNext.jsのドキュメント上ではApp Router、Pages Routerを切り替えることでそれぞれのRouterの機能と設定方法を確認することができます。App Routerのドキュメントを確認する場合は”Using App Router”を選択して読み進めてください。

Next.jsのドキュメント
Next.jsのドキュメント

”Routingの設定”では新機能のApp Routerについて説明を行なっていきますがPages Routerとの設定方法の違いを確認するため最初にPages Routerでのルーティング設定方法も確認しておきます。

本文書はPages Routerでのルーティング以外についてはApp Routerとの比較は行っていません。
fukidashi

Pages Router

インストール時にApp Routerを利用することを選択しましたがPages Routerも引き続き利用することができます。Pages Routerではpagesディレクトリの下にルーティングのファイルを作成していくため新たにプロジェクトディレクトリ直下にpagesディレクトリを作成することから始めます。pagesディレクトリの作成が完了したらabout.tsxファイルを作成します。


const About = () => {
  return <div>About</div>;
};

export default About;

ファイルを作成するだけでNext.jsが自動でルーティングが設定してくれるのでファイル名がそのままURLとなります。ブラウザからhttp://localhost:3000/aboutにアクセスするとabout.tsxファイルで記述した内容がそのままページに表示されます。このようにPages Routerではファイル名とURLが連動します。/about/index.tsxファイルと設定することも可能です。

Pages Routerでのaboutページの表示
Pages Routerでのaboutページの表示

App Router

App Routerではappディレクトリの下にファイルを作成していきます。プロジェクト作成時にApp Routerの利用を選択しているためデフォルトでappディレクトリは作成されています。

Pagesの設定

/aboutのルーティングを設定するためにはファイルではなくaboutディレクトリをappディレクトリの下に作成する必要があります。その後aboutディレクトリの下にpage.tsxファイルを作成します。ファイル名はTypeScriptであればpage.tsxまたはpage.ts、JavaScriptではpage.jsxまたはpage.jsとする必要があります。


const Page = () => {
  return <div>About</div>;
};

export default Page;

ファイルを保存するとApp RouterとPage Routerで別々に設定したaboutがコンフリクト(衝突)しているということでnpm run devコマンドを実行したターミナルにはエラーメッセージが表示されます。


- error Conflicting app and page file was found, please remove the conflicting files to continue:
- error   "pages/about.tsx" - "app/about/page.tsx"
本文書ではPages Routerの説明のためPages Routerでも/aboutのルーティングを設定しているためにコンフリクトが発生しています。Pages Routerを利用していない場合やApp Routerと同じ名前のルーティングを設定していない場合にはエラーはできません。
fukidashi

コンフリクトの問題を解消するためにappディレクトリの設定かpageディレクトリの設定のどちらかを変更する必要がありますがここではpagesディレクトリを削除します。pagesディレクトリのabout.tsxファイルのファイル名を変更することでもエラーは解消されます。エラーの解消後はappディレクトリで設定したabout/page.tsxファイルの内容がブラウザに表示されます。

App Routerでのaboutページの表示
App Routerでのaboutページの表示

Layoutの設定

ブラウザ上に表示されたaboutページの画面にはグラデーションが入っていますがpage.tsxファイルにはCSSによるスタイルを設定していません。ではなぜpage.tsxファイルにCSSが設定されていないにも関わらずCSSによるスタイルが設定されているのでしょう。その理由はappディレクトリ下のlayout.tsxファイルの設定が反映されているためです。layout.tsxは名前の通りレイアウトに関するファイルでApp Routerではappディレクトリ直下に保存されたlayoutx.tsxファイルがすべてのpage.tsxに適用されます。layout.tsxの中身を確認します。html, bodyが利用されており、childrenの部分にpage.tsxのコンテンツが挿入されます。


import './globals.css'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

layout.txsファイルではglobals.cssがimportされていることがわかります。globals.cssファイルの中でbodyタグに対してCSSが設定されているのでCSSを削除するとグラデーションは消えます。

appディレクトリ直下のlayout.txsファイルは必須ファイルのためlayout.tsxファイルの名前を変更したり削除するとpage.tsxファイルにlayoutが存在しないためNext.jsが自動でlayout.tsxファイルを作成します。

Nested Layoutの設定

Layoutファイルはappディレクトリ直下だけではなくPageディレクトリの配下にあるpage.tsxファイに共通のLayoutを適用したい場合にPageディレクトリの直下にlayout.tsxを作成することができます。

aboutディレクトリの下にlayout.tsxファイルを作成します。Tailwind CSSのUtility Classを利用して中央に表示するように設定しています。


export default function AboutLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="flex justify-center items-center h-screen">{children}</div>
  );
}
Nested Layoutの適用
Nested Layoutの適用

aboutディレクトリの中にさらに別のPageディレクトリを作成することができます。例えばaboutディレクトリの下にinfoディレクトリを作成してpage.tsxファイルを作成するとaboutディレクトリに作成したlayout.tsxファイルが適用されます。aboutディレクトリだけではなくappディレクトリ直下にあるlayout.tsxファイルも適用されます。

Route Groupの設定

App Routerではappディレクトリにディレクトリを作成することでルーティングを作成していきますがルーティングのパスに影響を与えないディレクトリを作成することができます。その方法の一つにRoute Groupがあります。Route Groupを設定するディレクトリは()で囲む必要があります。()で囲んだディレクトリ下にはパスに影響を与えることはありませんがlayout.tsxファイルを作成することができるのでRoute Group以下のすべてのページに作成したlayout.tsxファイルを適用することができます。言葉よりも実際に設定した方が理解が進むので任意の名前のmarketingディレクトリを作成します。

(marketing)ディレクトリの下にはlayout.tsxファイルを作成して以下のコードを記述します。


export default function MarketingLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return <div className="m-4 font-bold">{children}</div>;
}

(marketing)ディレクトリの直下にaccountディレクトリを作成してpage.tsxファイルを作成します。


const Page = () => {
  return <div>Account</div>;
};

export default Page;

ブラウザからアクセスする場合は()で囲んだディレクトリはルーティングのパスに影響を与えないのでmarketingを省いて/accountでアクセスを行うことができます。

Accountページの表示
Accountページの表示

(marketing)ディレクトリの下にはaccount以外にもPageディレクトリを作成してpage.tsxファイルを作成すると(marketing)ディレクトリの下のlayout.tsxファイルが適用されます。

Dynamic Routesの設定

ここまでの設定ではaboutやaccountなどルーティングが静的でURLが変わらないページの設定を行いました。しかし実際のアプリケーションでは静的なURLばかりではなく例えばブログの記事を表示したい場合には/blog/1, /blog/2, /blog/what-is-next.js…などのように動的に変わるルーティングに対応させる必要があります。

Dynamic Routesを設定するためにappディレクトリ直下にblogディレクトリを作成します。/blog/1, /blog/2でアクセスするためにblogディレクトリの下にさらにディレクトリを作成しますがDynamic Routesの場合は[]でディレクトリ名を囲みます。ここでは[id]ディレクトリを作成します。idは任意なのでslugやblogIdと設定することもできます。名前は任意ですが後ほどこの名前はコードの中で利用するので役割に応じた適切な名前をつけてください。

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


const Page = () => {
  return <div className="m-4 font-bold">Blog ID:</div>;
};

export default Page;

/blog/1, /blog/2, … /blog/100でアクセスすることが可能になり、/blog/以下にどのような文字列を設定してもpage.tsxファイルに記述した内容が表示されます。

Dynamic Routesの設定によるページの表示
Dynamic Routesの設定によるページの表示
/blot/1/testのように”/”をつけた場合は404ページが表示されます。後ほど”catch-all-segmentsの設定”で対応方法を説明しています。
fukidashi

Dynamic Routesのページでは/blog/以下に指定した値はPropsを利用して取得することができます。最初はどのような値がPropsに含まれているかわからないのでconsole.logを利用してpropsの値を取得します。


const Page = (props) => {
  console.log(props);
  return <div className="m-4 font-bold">Blog ID:</div>;
};

export default Page;

ブラウザから/blog/100にアクセスするとブラウザのデベロッパーツールのコンソールではなく開発サーバを起動したターミナルにpropsの値が表示されます。ターミナルに表示されることからpageファイルのコードがサーバ側で実行されていることがわかります。


{ params: { id: '100' }, searchParams: {} }

idはディレクトリ名に設定した名前と一致し[slug]という名前にした場合はidではなくslugプロパティとして保存されます。

propsに含まれるオブジェクトがわかったのでTypeScriptを利用している場合はPropsの型を指定しparamsに含まれるidをブラウザ上に表示させます。


const Page = ({ params }: { params: { id: string } }) => {
  return <div className="m-4 font-bold">Blog ID: {params.id}</div>;
};

export default Page;
ブラウザ上にidの値を表示
ブラウザ上にidの値を表示

/blog/以下の値を変更するとその値がブラウザに表示されます。1, 100などの数値ではなくwhat-is-nextjsなどの文字列でも表示されます。

ここではURL/blog/以下の値を取り出し表示させるだけでしたが実際のアプリケーションではこの値を利用してデータベースにアクセスしてレコードを取得したり、さらに別のサーバにアクセスを行いデータを取得してページを表示するといった設定を行います。

catch-all-segmentsの設定

/blog/1, /blog/2, …, /blog/100でアクセスを行うことができましたが/blog/1/2/3でアクセスが行われた場合にはどのような方法で対応するのか確認していきます。

/blog/1/2/3でアクセスした場合にpropsのparamsどのような値が含まれるかconsole.logを利用してparamsの中身を確認します。


const Page = ({ params }) => {
  console.log(params);
  return <div className="m-4 font-bold">Blog ID: </div>;
};

export default Page;

ブラウザから/blog/1/2/3にアクセスすると404ページが表示されるためparamsの値を確認することはできません。

Not Foundページの表示
Not Foundページの表示

1,2,3の値を取得するためにはディレクトリ名を[id]から[…id]に変更します。設定後、再度ブラウザからアクセスすると配列の形で1,2,3の値を取得することができます。


{ id: [ '1', '2', '3' ] }

paramsの型も下記のように配列で設定します。


const Page = ({ params }: { params: { id: string[] } }) => {
  console.log(params);
  return <div className="m-4 font-bold">Blog ID: </div>;
};

export default Page;

こちらは通常の設定方法ですが、/blog/1/2/3でページを表示させるためには[id]ディレクトリの下に[userId]、さらにその下に[categoryId]を作成して[categoryId]の下にpage.tsxファイルを作成します。useIdとcatagoryIdは任意の名前をつけることができますがid, userId, categoryIdと異なる名前をつけてください。同じ名前をつけた場合には”Error: You cannot have the same slug name “id” repeat within a single dynamic path”のメッセージが表示されます。


const Page = ({ params }: { params: { id: string[] } }) => {
  console.log(params);
  return <div className="m-4 font-bold">Blog ID: </div>;
};

export default Page;

ターミナルにはオブジェクトとして下記のように表示されます。


{ id: '1', userId: '2', categoryId: '3' }

paramsの型は下記のように設定します。


const Page = ({
  params,
}: {
  params: { id: string; userId: string; categoryId: string };
}) => {
  console.log(params);
  return <div className="m-4 font-bold">Blog ID: </div>;
};

export default Page;

Linkの設定

Linkコンポーネントを利用することでページ間をスムーズに移動することができます。aboutページから/(ルート)ページに移動できるようにLinkコンポーネントを利用して設定を行います。Linkコンポーネントを利用するためにはnext/linkのimportが必要となります。Linkコンポーネントではhref propsに移動先のページのURLを設定します。


import Link from 'next/link';

const Page = () => {
  return (
    <div className="flex flex-col items-center">
      <Link href="/" className="underline">
        Home
      </Link>
      <h1 className="text-2xl">About</h1>
    </div>
  );
};

export default Page;

aboutページにアクセスを行い、表示されているHomeの文字列をクリックすると/(ルート)へ移動します。

Linkコンポーネントの設定
Linkコンポーネントの設定

appディレクトリのpage.tsxファイルにもLinkコンポーネントを設定してaboutページへ移動できるように設定を行っておきます。


import Link from 'next/link';

export default function Home() {
  return (
    <div className="m-4">
      <Link href="/about" className="underline">
        About
      </Link>
      <h1 className="text-2xl">Home</h1>
    </div>
  );
}

Aboutのリンクをクリックするとaboutページに移動できません。

ルートページからaboutページへのリンク設定
ルートページからaboutページへのリンク設定

prefetchの設定

Linkコンポーネントではデフォルトからprefetchの機能が設定されています。開発環境ではリンクにカーソルを当てるとリンク先のページに関するJavaScriptファイルなどがバックグランドで自動でダウンロードされます。本番環境ではViewportに入っているリンク先のファイルがバックグランドで自動でダウンロードされます。

prefect機能を利用したくないという場合はLinkコンポーネントのprefetch propsをfalseにすることで無効化できます。


<Link href="/about" className="underline" prefetch={false}>

Parallel Routesの設定

Parallel Routingを利用することで1つのLayoutで複数のPageコンポーネントを表示させることができます。

appディレクトリの下に@analytics, @teamの2つのディレクトリを作成します。Route Groupでは()を利用しましたがParallel Routingでは@をディレクトリの先頭につけることでルーティングのパスに影響されないPageコンポーネントとなります。それぞれのディレクトリの下にpage.tsxファイルを作成して以下のコードを記述します。


const Page = () => {
  return (
    <div className="m-4">
      <h1 className="text-2xl">Analytics</h1>
    </div>
  );
};

export default Page;

const Page = () => {
  return (
    <div className="m-4">
      <h1 className="text-2xl">Team</h1>
    </div>
  );
};

export default Page;

追加したPageコンポーネントはappディレクトリ直下のlayout.tsxファイルで設定を行います。childrenと同様にteamとanalyticsをpropsで設定します。


import './globals.css';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
  team,
  analytics,
}: {
  children: React.ReactNode;
  team: React.ReactNode;
  analytics: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <div>{children}</div>
        <div>{team}</div>
        <div>{analytics}</div>
      </body>
    </html>
  );
}

以上で設定は完了です。

/(ルート)にアクセスするとchildretに対応するapp/page.tsxと@analytics, @teamのpage.tsxファイルの内容がブラウザ上に表示されます。

複数のPageコンポーネントを表示
複数のPageコンポーネントを表示

Aboutページのリンクをクリックしても引き続き複数のPageコンポーネントがブラウザ上に表示されます。

Aboutページでも複数のPageコンポーネント表示
Aboutページでも複数のPageコンポーネント表示

ここまでは設定通りに動作しましたが/aboutページでリロードを行ってください。リロードを行うと404ページが表示されます。

404ページの表示
404ページの表示

Linkコンポーネントによるページ移動(Soft Navigation)では設定通りに動作しますがリロードのように直接ページにアクセスするような場合(Hard Navigation)には設定通りには動作しません。

404ページを表示させないためには@analytics, @teamディレクトリの下にdefalut.tsxファイルを作成する必要があります。


export default function Default() {
  return <div className="m-4">Analytics Page</div>;
}

export default function Default() {
  return <div className="m-4">Team Page</div>;
}

defalut.tsxファイルを作成後、/aboutのページでリロードを行うとdefault.tsxファイルに記述した内容が表示されます。

default.tsxファイルの設定後
default.tsxファイルの設定後

@analyticsディレクトリのdefault.tsxファイルでのみ同じディレクトリにあるpage.tsxをimportして表示されるか確認を行います。


import Page from './page';
export default function Default() {
  return <Page />;
}

importしたPageコンポーネントが表示されることが確認できます。

Pageコンポーネントのimport
Pageコンポーネントのimport

Loadingの設定

外部からのデータ取得

外部のデータリソースからfetch関数を利用してデータを取得するために無料で利用することができるJSONPlaceHolderを使います。JSONPlaceHolderが提供するURLにアクセスするとJSONデータを取得することができるので開発など外部のリソースを利用した動作確認に活用できます。

usersページを作成してユーザ一覧を表示させるためにappディレクトリの下にusersディレクトリを作成してpage.tsxファイルを作成します。


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

const Page = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  console.log(users);
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

App Routerを利用した場合デフォルトですべてのコンポーネントはServer Componentsとして動作するためサーバ側ですべての処理が行われます。Server Componentsではサーバ側でfetch関数が実行されるためconsole.logを利用した場合は開発サーバを実行しているターミナルに取得した情報が表示されます。クライアント(ブラウザ)でfetch関数を実行するた場合はブラウザのデベロッパーツールのコンソールには取得したデータが表示されますがServer Componentsの場合には表示されません。

ブラウザ側ではサーバで処理が完了したデータを受け取り描写することが確認できます。

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

Loading設定の動作確認のため、aboutページからusersページを移動できるようにaboutページのリンク先をHomeからUserに変更します。


import Link from 'next/link';

const Page = () => {
  return (
    <div className="flex flex-col items-center">
      <Link href="/users" className="underline">
        User
      </Link>
      <h1 className="text-2xl">About</h1>
    </div>
  );
};

export default Page;

aboutページからリンクを利用してuserページに移動できるようになりました。

遅延処理の追加

Server Componentsではサーバ側ですべての処理が行われるためデータの取得に時間がかかっている場合にどのような動作になるのか理解しておく必要があります。動作を確認するためPromiseとsetTimeoutを利用して意図的に遅延を作ります。遅延の処理を追加する前にaboutページからのusersページへのリンクをクリックすると即座にユーザ一覧が表示されることを確認しておきます。

usersディレクトリのpage.tsxファイルでfetch関数を実行する前に5秒間の遅延を追加します。


const Page = async () => {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('https://jsonplaceholder.typicode.com/users');

遅延処理を追加後にaboutページのusersページへのリンクをクリックします。クリックして5秒間何も画面に変化はありません。5秒経過するとユーザ一覧が表示されます。このことからServer Componentでの処理が完了するまでページが表示されないことがわかりました。

loading.tsxファイルの設定

処理が完了するまでページ上で何も変化がないのはユーザにとって気持ちのいいものではなく離脱につながります。ページが表示されない問題を解決するためにサーバ側での処理中にブラウザ上に現在データのローディング中であることを伝えるメッセージを表示させるためusersディレクトリにloading.tsxファイルを作成します。


export default function Loading() {
  return (
    <div className="flex justify-center items-center h-screen font-bold">
      ローディング中
    </div>
  );
}

aboutページのusersページへのリンクをクリックするとloading.tsxに設定した内容がブラウザ上に表示されます。loading.tsxファイルを設定することで現在データを取得中であることがわかるようになりました。

loadingのメッセージの表示
loadingのメッセージの表示

UserListコンポーネントの作成

手動で行うLoading設定の準備としてusers/page.tsxファイルからユーザ一覧の処理部分を取り出すためusersディレクトリにUserList.tsxファイルを作成します。


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

const UserList = async () => {
  await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

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


import UserList from './UserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      <UserList />
    </div>
  );
};

export default Page;

page.tsxファイルのUserListタグの箇所にTypeScriptに関するメッセージがに表示されます。エラーの表示される原因はUserListコンポーネントがasyncを利用した非同期のServer Componentのためです。asyncを利用していないコンポーネントの場合にメッセージは表示されません。

TypeScriptのメッセージ
TypeScriptのメッセージ

メッセージの表示を止めるためドキュメントに記載されている内容を元に一時的な対応策として”{/* @ts-expect-error Async Server Component */} “のコメントをUserListコンポーネントの上に追加します。追加するとTypeScriptに関するメッセージは表示されなくなります。


import UserList from './UserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      {/* @ts-expect-error Async Server Component */}
      <UserList />
    </div>
  );
};

export default Page;

動作確認を行うとUserListコンポーネントを作成する前と変わらずaboutページからUserページに移動するとブラウザ上に”ローディング中”の文字が表示されます。

page.tsxファイルはデフォルトでSever Componentとして動作しますがUserList.tsxファイルもどのようにデフォルトでServer Componentとして動作します。

手動でのSuspenseの設定

loading.tsxファイルを作成することでサーバでの処理中にLoadingのメッセージが表示されてるようになりましたがこれはReactが持つSuspenseの機能を利用して行われています。Next.jsがSuspenseの設定を自動で行ってくれるためSuspenseが利用されていることを意識することはありませんがloading.tsxファイルを利用せずSuspenseを明示的に利用して設定を行うことができます。

usersディレクトリに作成したloading.tsxファイルを削除するか別名で保存してください。

次にUserListタグをSuspenseタグでラップしてfallback propsにサーバ処理中にブラウザ上に表示させたいメッセージを設定します。Suspenseはreactからimportします。


import { Suspense } from 'react';
import UserList from './UserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      <Suspense fallback={<p>Loading...</p>}>
        {/* @ts-expect-error Async Server Component */}
        <UserList />
      </Suspense>
    </div>
  );
};

export default Page;

Suspenseの設定後、aboutページからusersページに移動します。先程とは異なりユーザ一覧の文字列は即座に表示されSuspenseでラップしたUserListコンポーネントの箇所にのみ”Loading…”の文字が表示されます。

ユーザ一覧の文字は表示
ユーザ一覧の文字は表示

loading.tsxを利用した場合にはページ全体に対して自動でSuspenseが設定されているのでページ内のいずれかのコンポーネントのデータ取得処理が行われている場合はページ全体に対してloading.tsxファイルの内容が表示されます。Suspenseをコンポーネント単位でラップすることでそのコンポーネントに対するLoading設定を行うことができます。

別々のSuspeseタグでラップした複数のコンポーネントを1つのページに設定して異なる時間で遅延を行った場合にどのような動作になるか確認します。usersディレクトリにOtherUserList.tsxファイルを作成してUserList.tsxファイルの内容をコピーして遅延の時間のみ変更を行います。遅延時間を2秒に設定しています。


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

const OtherUserList = async () => {
  await new Promise((resolve) => setTimeout(resolve, 2000));
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default OtherUserList;

users/page.tsxファイルに作成したOtherUserListコンポーネントを追加します。UserListとOtherUseListコンポーネントには別々のSuspenseタグを設定して、fallbackのメッセージの内容を変更してUserListのfallbackには文字にカラーを設定しています。


import { Suspense } from 'react';
import UserList from './UserList';
import OtherUserList from './OtherUserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      <Suspense fallback={<p className="text-red-700">Loading UserList...</p>}>
        {/* @ts-expect-error Async Server Component */}
        <UserList />
      </Suspense>
      <Suspense fallback={<p>Loading OtherUserList...</p>}>
        {/* @ts-expect-error Async Server Component */}
        <OtherUserList />
      </Suspense>
    </div>
  );
};

export default Page;

aboutページからusersページに移動直後はどちらもサーバ上で処理が行われているためfallbackのメッセージが表示されます。

2つのコンポーネントがサーバ上で処理中
2つのコンポーネントがサーバ上で処理中

2秒経過するとOtherUserListのサーバ上での処理が完了してユーザ一覧が表示されます。UserListは引き続きサーバ上で処理を行っているのでfallbackのメッセージが表示されています。

2秒後に1つのコンポーネントの処理が完了
2秒後に1つのコンポーネントの処理が完了

5秒経過するとUserListのサーバ上での処理も完了するのでUserListコンポーネントの処理で取得したユーザ一覧が表示されます。

このように Suspenseタグを利用することですべてのページ上の処理が完了して一括でブラウザ上に表示されるのではなく処理が完了したコンポーネント毎にサーバからデータを受け取りブラウザ上に表示させることができます。この機能をStreamingと呼びます。

Error Handling

users/page.tsxファイルとUserList.tsxファイルを利用してError Handlingの動作確認を行います。

users/page.tsxファイルではUserListコンポーネントをimportしています。


import UserList from './UserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      {/* @ts-expect-error Async Server Component */}
      <UserList />
    </div>
  );
};

export default Page;

UserList.tsxファイルではfetch関数によるデータ処理に失敗した場合にエラーをthrowさせるためresponse.okを使って分岐を行います。fetch関数の処理に失敗した場合にresponseオブジェクトのokプロパティにはfalseが入ります。


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

const UserList = async () => {
  // await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!response.ok) throw new Error('Failed to fetch data');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

fetch関数の引数で設定しているURLを存在しないhttps://jsonplaceholder.typicode.com/userに変更します。”users”から”s”を削除して”user”としています。存在しないURLにアクセスを行うとresponse.okの値がfalseになるためErrorがthrowされます。

Userページ(/users)にブラウザからアクセスするとエラーによりUnhandled Runtime Error画面が表示されます。

Unhandled Rutime Errorの画面が表示
Unhandled Rutime Errorの画面が表示

右上に表示されている”X”ボタンをクリックすると画面の左下にボックスが表示され再度”X”をクリックすると先程表示されていた”Unhandled Runtime Error”の画面が表示されます。

エラー画面
エラー画面

Unhandled Runtime Error画面ではなくエラーメッセージのみを表示させるようにError Handlingの設定を行なっていきます。

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


'use client';

export default function Error({ error }: { error: Error }) {
  return (
    <div className="m-4 font-bold">
      <p>{error.message}</p>
    </div>
  );
}

Pageディレクトリの直下にerror.tsxファイルを作成するとNext.jsが自動でUnhandled Runtime Errorが発生した場合にエラーをハンドリングするためerror.tsファイルが利用されます。エラーハンドリングにはReactのErrorBoundaryを利用しておりErrorBoundaryタグでpage.tsxをラップする形で設定が行われます。

App RouterではデフォルトではすべてのコンポーネントがServer Componentでサーバ側で処理が行われます。error.tsファイルはクライアント(ブラウザ)側で処理を行う必要があるためファイルの先頭に”use client”を追加する必要があります。”use client”を明示的に設定することでコンポーネントがServer ComponentからClient Componentに変わります。

error.tsxファイルを設定後にusersにアクセスするとerror.tsxファイルで設定したメッセージがブラウザ上に表示されます。左下に表示されているボックスの”X”をクリックするとUnhandled Runtime Errorの画面が表示されます。

エラーメッセージの表示
エラーメッセージの表示

エラー画面からの復帰

ここでの設定では存在しないURLを設定しているので何度/usersにアクセスしてもエラーが発生します。しかし本番では一時的にエラーが発生しているため再度/usersにアクセスするとエラーが解消に正常に動作することもあります。エラーが解消した場合にエラー画面から復帰するためにreset関数が準備されているのでreset関数の処理をerror.tsxファイルに追加します。


'use client';

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="m-4 font-bold">
      <p>{error.message}</p>
      <button
        className="px-2 py-1 text-white bg-blue-500 rounded-lg"
        onClick={() => reset()}
      >
        Try again
      </button>
    </div>
  );
}

/usersにアクセスするとエラーメッセージと”Try again”ボタンが表示されます。

Try againボタンの表示
Try againボタンの表示

“Try again”ボタンをクリックしても現在の設定では引き続きエラーが発生するので同じ画面が再表示されます。

Client Component

App RouterではすべてのコンポーネントはデフォルトでServer Componentです。クライアント(ブラウザ)側でインタラクティブな操作を行うためにはClient Componetとして設定を行う必要があります。

ユーザがボタンをクリックするとカウンターの数字が増えるCounterコンポーネントを利用してClient Componentについて確認していきます。

Counterコンポーネントの作成

appディレクトリの直下にCounter.tsxファイルを作成して以下のコードを記述します。これまでのコンポーネントとは異なりuseState Hookを利用しています。ボタンをクリックするとuseStateで定義した状態countの値が増えるだけのシンプルなコードです。


import { useState } from 'react';

const Counter = () => {
  const [count, setCount] = useState<number>(0);
  const increment = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <div>Count: {count}</div>
      <button
        onClick={increment}
        className="px-2 py-1 rounded-lg bg-blue-600 text-white"
      >
        Increment
      </button>
    </>
  );
};

export default Counter;

作成したCounterコンポーネントをappディレクトリ直下にあるpage.tsxファイルからimportします。


import Link from 'next/link';
import Counter from './Counter';

export default function Home() {
  return (
    <div className="m-4">
      <Link href="/about" className="underline">
        About
      </Link>
      <h1 className="text-2xl">Home</h1>
      <Counter />
    </div>
  );
}

Failed to compileのエラー画面が表示されます。エラーの原因も丁寧に表示されています。”You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.”

Failed to compileエラー
Failed to compileエラー

デフォルトではServer Componentsとして動作するためuseState Hookを利用するためには’use client’の設定を行いClient Componentとして動作させる必要があります。

指摘通り、Counter.tsxファイルの先頭に’use client’の1行を追加します。’use client’はimport文よりも前のファイルの先頭に記述する必要があります。

‘use clinet’を設定したことでエラーが解消されブラウザ上にはカウンターが表示され”increment”ボタンをクリックするとCountの値が増えていきます。

Counterの表示
Counterの表示

Client Component

‘use client’を追加することでServer ComponentからClient Componentになりクライアント(ブラウザ)側でuseState Hookを利用した処理が行えるようになりました。

Counter.tsxファイルの親コンポーネントであるapp/page.tsxファイルに’use client’を設定しても動作するか確認します。Counter.tsxの’use client’は削除しておきます。


'use client';
import Link from 'next/link';
import Counter from './Counter';

export default function Home() {
//略

親コンポーネントに’use client’の設定を行なってもCounterは動作します。Pageコンポーネントに含まれているコンポーネントがClient Componentの場合は親コンポーネントに’use client’に設定することで各コンポーネントで’use client’を設定する必要がなくなります。

動作確認が完了したらpage.tsxから’use client’を削除してCounter.tsxファイルの先頭に’use client’を戻してください。

Server ComponentをClient Componentで利用

Server ComponentをClient Component内で利用したい場合にはpropsを利用します。

Server Componentとしてusersディレクトリに作成済みのUserLst.tsxファイルを利用します。サーバ側でのみ実行されるか確認するためにconsole.logを設定しています。


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

const UserList = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!response.ok) throw new Error('Failed to fetch data');
  const users: User[] = await response.json();
  console.log(users);
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

Counter.tsxファイルではServer Componentを受け取れるようにchildren propsを設定します。


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

const Counter = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = useState<number>(0);
  const increment = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <div>Count: {count}</div>
      <button
        onClick={increment}
        className="px-2 py-1 rounded-lg bg-blue-600 text-white"
      >
        Increment
      </button>
      {children}
    </>
  );
};

export default Counter;

最後にappディレクトリのpage.tsxファイルでUserListコンポーネントをimportしてCounterタグの間に挿入します。


import Link from 'next/link';
import Counter from './Counter';
import UserList from './users/UserList';

export default function Home() {
  return (
    <div className="m-4">
      <Link href="/about" className="underline">
        About
      </Link>
      <h1 className="text-2xl">Home</h1>
      <Counter>
        <h2 className="font-bold text-lg mt-4">ユーザ一覧</h2>
        {/* @ts-expect-error Async Server Component */}
        <UserList />
      </Counter>
    </div>
  );
}

console.logの内容は開発サーバを起動したターミナルに表示されることが確認できpropsを利用することでClient Componentの中でServer Componentが利用できることが確認できました。

Client Component内でServer Componetを利用
Client Component内でServer Componetを利用

Client Componentのpre-rendered

Next.jsのドキュメントには”Client components are pre-rendered on the server as HTML to produce a faster initial page load”(最初のページ読み込みを高速化するために、サーバー上で HTMLとしてプリレンダリングされます。)と記載されているので動作確認を行います。

/(ルート)のページにアクセスを行い最初のページ読み込みを行うためリロードします。リロード後にClient Componetが事前にプリレンダリングされているか確認するためネットワークタブを確認します。

ブラウザ側ではサーバからのResponseとしてプリレンダリングされたHTMLとして受け取っていることが確認できます。

pre-renderedされたページの確認
pre-renderedされたページの確認

Server Components vs Client Components

Server ComponentsとClient Componentsの使い分けについてはNext.jsの公式ドキュメントに掲載されているので参考にしてください。

When to use Server and Client Components?
When to use Server and Client Components?

Fetch data, バックエンドリソース(データベースなど)への直接なアクセス, アクセストークンやAPIキーの利用、大きさサイズのパッケージを利用する処理についてはServer Components、onClickなどユーザとのインタラクティブがあるもの、useStateなどのReact Hook, ブラウザのAPIなどについてはCliet Componentsを利用することを推奨しています。Fetch dataなどはクライアントでも行えますし、アクセストークンなども利用できますがアクセストークンをクライアントコンポーネント内で利用するとアクセストークンの中身がユーザから閲覧できてしまうのServer Componentsを利用することになります。

Server Componentのみで利用するパッケージはブラウザ側でダウンロードされることがないのでJavaScriptのバンドルサイズを小さくすることができます。そのため大きなサイズのパッケージはServer Componentで利用することが推奨されているのでKeep large dependencies on the serverはServer Component側にチェックが入っています。大きなサイズのパッケージがClient Componentで利用できないわけではありませんがダウンロードするJavaScriptのバンドルが大きくなのでパフォーマンスへの影響が出てきます。

Context

コンポーネント間でデータを共有したい場合にContextを利用することができます。先ほど作成したCounterの処理をContextを利用して書き換えます。

ContextはClient Componentでしか利用することはできません。そのためClient Componentの中で定義する必要があります。

プロジェクトディレクトリの直下にcontextディレクトリを作成してConterProvider.tsxファイルを作成します。Client Componentの設定を行うためファイルの先頭には’use client’を設定します。それ以外については通常のTypeScriptのコードと違いはありません。


'use client';

import React from 'react';

const CounterContext = React.createContext<
  [number, React.Dispatch<React.SetStateAction<number>>] | undefined
>(undefined);

export function CounterProvider({ children }: { children: React.ReactNode }) {
  const [count, setCount] = React.useState(0);
  return (
    <CounterContext.Provider value={[count, setCount]}>
      {children}
    </CounterContext.Provider>
  );
}

export function useCounter() {
  const context = React.useContext(CounterContext);
  if (context === undefined) {
    throw new Error('useCounter must be used within a CounterProvider');
  }
  return context;
}

作成したConterProviderをappディレクトリ直下のlayout.tsxファイルでimportします。


import './globals.css';
import { Inter } from 'next/font/google';
import { CounterProvider } from './context/CounterProvider';

const intr = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={intr.className}>
        <CounterProvider>{children}</CounterProvider>
      </body>
    </html>
  );
}

Contextの設定は完了です。RootLayoutでContextを行ったのでapp下のClient ComponentであればContextを利用することができます。ここではappディレクトリ直下に作成済みのCounter.tsxファイルで利用します。Client Compnentでしか利用できないため’use client’をファイルの先頭に記述しています。ConterProviderからuseCounter関数をimportすることでcount, setCountをコード内で利用することができます。


'use client';
import { useCounter } from './context/CounterProvider';

const Counter = ({ children }: { children: React.ReactNode }) => {
  const [count, setCount] = useCounter();
  const increment = () => {
    setCount((prev) => prev + 1);
  };
  return (
    <>
      <div>Count: {count}</div>
      <button
        onClick={increment}
        className="px-2 py-1 rounded-lg bg-blue-600 text-white"
      >
        Increment
      </button>
      {children}
    </>
  );
};

export default Counter;
‘use client’を設定せずServerr ComponentとしてContextを利用しようとした場合には”Error: Attempted to call useCounter() from the server but useCounter is on the client. It’s not possible to invoke a client function from the server, it can only be rendered as a Component or passed to props of a Client Component.”というメッセージでunhandeld Runtime Errorが表示されるのでメッセージの内容から原因を特定することができます。
fukidashi

表示される内容はContextを利用する前と変わりませんがincrementボタンをクリックするとCountの数が増えます。

Client Component内でServer Componetを利用
Contextを利用してCountの値をincrement

Route Handlers

App RouterのRoute HandlersはPages RouterのAPI Routesと同等の機能です。Route Handlersを利用することでGET, POSTなどのHTTPメソッドを利用してアクセスすることでAPIエンドポイントを設定するができます。Route Handlersも

appディレクトリの中に任意の名前のディレクトリを作成します。ここではapiという名前のディレクトリを作成します。Pageコンポーネントの名前がpage.tsxで決められているようにRoute Handlersではroute.jsまたはroute.tsという名前をつける必要があります。

GETリクエストの動作確認

apiディレクトリにroute.tsファイルを作成して以下のコードを記述します。


import { NextResponse } from 'next/server';

export function GET() {
  return NextResponse.json({ name: 'John Doe' });
}

/apiに対してGETリクエストを送信するとJSONで{“name”:”John Doe”}が戻されるというもっともシンプルなコードです。

GETリクエストであればブラウザからアクセスすることで動作確認できます。

ブラウザから/apiへのアクセス
ブラウザから/apiへのアクセス

Route Handlersからのデータ取得

JSONPlaceHolderからデータを取得することもできます。


import { NextResponse } from 'next/server';

export async function GET() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
  return NextResponse.json(data);
}

ブラウザからもアクセスできますがUserListコンポーネントからアクセスを行ってみます。


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

const UserList = async () => {
  // await new Promise((resolve) => setTimeout(resolve, 5000));
  const response = await fetch('http://localhost:3000/api');
  if (!response.ok) throw new Error('Failed to fetch data');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
};

export default UserList;

ブラウザから/usersにアクセスするとRoute Hadlersを経由してデータの取得が行われ、ユーザ一覧が表示されます。

URLパラメータの取得

検索などURLパラメータを利用した場合のURLパラメータの取得方法を確認します。

UserLists.tsxファイルではfetch関数で指定するURLにパラメータを追加します。App Routerでfetch関数はWeb APIのfetch関数を拡張しているためオプションを設定することができます。デフォルトでは一度fetch関数が実行されるとキャッシュされるためその後fetch関数を実行するとキャッシュしたデータが利用されるためリクエストが行われません。ここでは動作確認のまたキャッシュ機能を無効にします。


const response = await fetch('http://localhost:3000/api?name=John', {
  cache: 'no-store',
});

api/route.tsではURLパラメータを取得するためrequestオブジェクトを利用します。requestオブジェクトにはさまざまな情報が含まれていますがURLパラメータはrequest.urlを利用して取得します。


import { NextResponse } from 'next/server';

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const name = searchParams.get('name');
  console.log(name);
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
  return NextResponse.json(data);
}

ブラウザから/usersにアクセスすると開発サーバを起動したターミナルに”John”が表示されます。URLパラメータを取得することができるようになりました。

headers, cookies関数

headers, cookies関数を利用することでそれらの情報を取得することができます。


import { NextResponse } from 'next/server';
import { headers, cookies } from 'next/headers';

export async function GET() {
  const headersList = headers();
  const cookieStore = cookies();

  console.log('headersList', headersList);
  console.log('cookieStore', cookieStore);

  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
  return NextResponse.json(data);
}

Dynamic Routesの設定

apiのように静的な APIのエンドポイントではなくapi/1, api/2,…api/100などのようにURLが動的に変わる場合の設定方法を確認します。

apiディレクトリの下に[]で囲んだ[id]ディレクトリを作成してその下にroute.tsファイルを作成します。


import { NextResponse } from 'next/server';

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  const id = params.id;
  return NextResponse.json(id);
}

ブラウザから/api/100にアクセスすると”100″が戻されます。

実際のアプリケーションではこの値を利用してデータベースにアクセスしてレコードを取得したり、さらに別のサーバにアクセスを行いデータを取得してページを表示するといった設定を行います。

POSTの設定

POSTリクエストによって送信されてきたデータを取得する方法を確認します。

api/route.tsファイルでPOSTリクエストで送信されてきたデータを取り出すためのコードを追加します。関数の名前はPOSTとなります。request.json()で取得したデータをそのままクライアントに戻しています。通常はデータベースなどへのデータの挿入などを行います。


import { NextResponse } from 'next/server';

export async function GET() {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
  return NextResponse.json(data);
}

export async function POST(request: Request) {
  const res = await request.json();
  return NextResponse.json({ res });
}

POSTリクエストを送信するためには入力フォームを作成する必要がありますがここではfetch関数を利用してPOSTリクエストでデータを送信するコードのみusers/page.tsxファイルに追加します。


import UserList from './UserList';

const Page = async () => {
  const response = await fetch('http://localhost:3000/api', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      name: 'John',
      email: 'john@example.com',
    }),
  });

  const data = await response.json();

  console.log(data);

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      {/* @ts-expect-error Async Server Component */}
      <UserList />
    </div>
  );
};

export default Page;

/usersにアクセスを行い、bodyプロパティに送信したオブジェクトの中身が開発サーバを起動しているターミナルに表示されればRoute Handlersで正しくPOSTリクエストを受け取り、受け取ったデータをブラウザに戻していることになります。

以下のように表示されれば正しく動作しています。


{ res: { name: 'John', email: 'john@example.com' } }

実際のアプリケーションではPOSTリクエストから送信されてきたデータをデータベースに登録するといった処理を行います。

Fetching

これまで触れてきませんでしたがfetch関数を実行すると開発サーバを起動したターミナルに下記のようなメッセージが表示されていました。


-  ┌ GET /users 200 in 610ms
   │
   └──── GET http://localhost:3000/api 200 in 450ms (cache: MISS)

注目したいところはcacheの値で上記ではMISSと表示されています。これはcacheにデータが存在しないためデータを取得するためにfetch関数を実行しています。その後再度ページを開くとcacheの値がHITになっています。これはcacheの値を利用したのでfetch関数を実行されません。


-  ┌ GET /users 200 in 116ms
   │
   └──── GET http://localhost:3000/api 200 in 2ms (cache: HIT)

このようにデフォルトの設定ではcacheを利用するような設定になっています。

GETの行にはアクセスのあったURLとステータスコード、経過した時間が表示されています。cacheがHITした場合には時間がかなり短くなっていることがわかります。

fetch関数のcacheオプション

このようにApp Routerでfetch関数を利用すると自動でcacheを利用する設定になっています。これはWeb APIのfetch関数を拡張しているためでオプションを利用してキャッシュの設定を変更することができます。

デフォルトではforce-cacheが設定されています。force-cacheのほかにcacheを無効にするno-storeがあります。

どちらも値の名前でどのような設定か想像できると思いますがcacheプロパティの値にno-storeを設定します。


const response = await fetch('http://localhost:3000/api', {
  cache: 'no-store',
});

設定後は何度/usersにアクセスしてもcacheの値が”MISS”のままです。


-  ┌ GET /users 200 in 206ms
   │
   └──── GET http://localhost:3000/api 200 in 170ms (cache: MISS)

http://localhost:3000/apiにGETリクエストを送信しているので実際にリクエストが送信されているのか確認するためにconsole.logを設定します。


export async function GET() {
  console.log('GET Request');
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const data = await response.json();
  return NextResponse.json(data);
}

設定後は毎回アクセスするたびに”GET Request”が表示されます。つまりapi/route.tsにGETリクエストが送信されてきていることになります。


GET Request

cacheの設定を削除してデフォルトの状態に戻します。削除するとデフォルトのforce-cacheとなります。


const response = await fetch('http://localhost:3000/api');

cacheの値がHITとなり、fetch関数が実行されなくなり/api/routes.tsにGETリクエストが送信されなくなるためGET Reqeustのメッセージが表示されることはなくなりました。


-  ┌ GET /users 200 in 42ms
   │
   └──── GET http://localhost:3000/api 200 in 1ms (cache: HIT)

fetchのnext.revalidateの設定

cacheオプションではcacheを利用するかどうかの設定でしたがnext.revalidateオプションを設定することでcacheのライフタイムを指定することができます。設定したライフタイムを経過するとfetch関数が新たに実行されます。

revalidateの値で秒数を設定することができるので下記では5秒を設定しています。fetchでアクセスを行い5秒間の間はcacheの値を利用しますがその時間を過ぎると再度fetch関数が行われます。


const response = await fetch('http://localhost:3000/api', {
  // cache: 'no-store',
  next: { revalidate: 5 },
});

Automatic Request Deduping

Automatic Request Dedupingは複数のコンポーネントで同じURLに対して同時にリクエストを送信する際にリクエストの重複をなくしてリクエストを最適化する機能です。

下記はNext.jsのドキュメントに記載されているイメージ図ですが複数のコンポーネントの重複したリクエストを最適化しているのが理解できるかと思います。この機能により重複したリクエストの数を減らすことができます。

deduplicated fetch request
deduplicated fetch request

Database

Prismaと手軽に利用できるSQLiteデータベースを利用してServer CompoentからPrismaを経由してSQLiteデータベースにアクセスを行い、データが取得できるかを確認します。

Prismaのインストール

Prismaを設定してSQLiteデータベースに接続するためにprismaパッケージのインストールを行います。


 % npm install prisma --save-dev

Prismaの設定

Prisma用の設定ファイルを作成するためにnpx prisma initコマンドを実行します。実行するとプロジェクトディレクトリ下にはprismaディレクトリと.envファイルが作成されます。prismaディレクトリにはPrismaの設定ファイルであるschema.prismaファイルが作成されています。.envファイルはデータベースに接続するために必要となる環境変数を設定するために利用します。SQLiteデータベースを利用するのでオプション–datasource-providerにsqliteを設定します。オプションを指定しない場合はデフォルトでデータベースにはpostgresqlが設定されています。


%  npx prisma init --datasource-provider sqlite

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started

schema.prismaファイルでは–datasource-providerを指定した実行した場合はSQLiteデータベースに関する設定は完了しているのでモデルの設定を行います。モデルにはpostテーブルを作成するためのスキーマ(テーブルの構成情報)を追加します。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  content String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

schema.prismaファイルでのモデルの完了したらSQLiteデータベースにpostテーブルを作成するためにnpx prisma db pushコマンドを実行します。


 % npx prisma db push
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Datasource "db": SQLite database "dev.db" at "file:./dev.db"

SQLite database dev.db created at file:./dev.db

🚀  Your database is now in sync with your Prisma schema. Done in 16ms

Running generate... (Use --skip-generate to skip the generators)

//略

✔ Generated Prisma Client (4.14.0 | library) to ./node_modules/@prisma/client in 87ms

コマンドを実行すると.envファイルのDATABASE_URLで指定した場所にSQLiteのデータベースファイルが作成されます。schema.prismaファイルのmodel以外の設定を変更していない場合にはprismaフォルダにdev.dbファイルが作成されます。

Prisma Studioからのデータベース接続

PrismaにはPrisma StudioというPrisma専用のツールを利用してデータベースにアクセスを行うことができます。Prisma Studioを起動するためにnpx prisma studioコマンドを実行します。


 % npx prisma studio
Environment variables loaded from .env
Prisma schema loaded from prisma/schema.prisma
Prisma Studio is up on http://localhost:5555

ブラウザからhttp://localhost:5555にアクセスするとPostテーブルをブラウザ上から確認することができます。

Prisma Studioからのデータベースへのアクセス
Prisma Studioからのデータベースへのアクセス

ブラウザからデータを挿入することができるので動作確認用に2件のデータを追加します。

2件のデータ挿入
2件のデータ挿入

Prisma Clientの設定

Next.jsからデータベースに接続するためにPrisma Clientの設定を行う必要があります。設定を行うためにlibディレクトリをプロジェクトフォルダ直下に接続を行い、prisma.tsファイルを作成します。


declare global {
  var prisma: PrismaClient; 
}

import { PrismaClient } from '@prisma/client';

let prisma: PrismaClient;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;

Next.jsからデータベースに接続する場合はこのファイルからPrisma Clientのimportを行います。これでPrismaの設定は完了です。

データの表示

Prismaを経由してSQLiteのpostテーブルに保存されているデータを取得して表示するためにappディレクトリの下にpostディレクトリを作成してpage.tsxファイルを作成し以下のコードを記述します。


import prisma from "@/lib/prisma';

const Page = async () => {
  const posts = await prisma.post.findMany();

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">記事一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

ブラウザから/postsにアクセスするとpostテーブルに保存したtitleが表示されます。

postテーブルに保存されたデータの表示
postテーブルに保存されたデータの表示

page.tsxファイルはServer Componentとして動作しているのでpage.txsファイルにデータベースへの接続処理のコードを記述することができます。

Route Handlersを利用した場合

Route Handlersを利用してAPIエンドポイントを作成した場合の動作確認も行っておきます。

appディレクトリ下にapiディレクトリを作成します。さらにpostsディレクトリを作成route.tsファイルを作成して以下のコードを記述します。


import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

export async function GET() {
  const posts = await prisma.post.findMany();
  return NextResponse.json(posts);
}

posts/page.tsxファイルはfetch関数を利用して作成した/api/postsからデータを取得します。


import { Post } from '@prisma/client';

const Page = async () => {
  const response = await fetch('http://localhost:3000/api/posts');
  const posts: Post[] = await response.json();

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">記事一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default Page;

ブラウザから/postsにアクセスすると先ほどと同じ画面が表示されます。

postテーブルに保存されたデータの表示
postテーブルに保存されたデータの表示

データの登録

フォームを利用してデータベースにデータを登録する方法を確認します。

Next.js 13.4で新たに”Server Actions: Mutate data on the server with zero client JavaScript”が登場しました。Server Actionsについては現在アルファなので今後も仕様の変更が考えられるため下記の記事で紹介しています。そのため本文書ではServer Actionsを利用していません。

入力フォームに2つのinput要素を持ち、Postモデルで定義したtitleとcontentを入力してsubmitボタンをクリックするとhandleSubmit関数が実行され、Router HandlersにPOSTリクエストが送信されるシンプルなフォームです。ファイル名をAddPost.tsxファイルとしてpostsディレクトリの下に作成します。


import { useState } from 'react';

export default function AddPost() {

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');
  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
    event
  ) => {
    event.preventDefault();

    await fetch('http://localhost:3000/api/posts/', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, content }),
    });

    setTitle('');
    setContent('');
  };

  return (
    <form onSubmit={handleSubmit} className="flex flex-col space-y-4 mt-8">
      <div>
        <label htmlFor="title">title:</label>
        <input
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          className="border"
          required
        />
      </div>
      <div>
        <label htmlFor="content">content:</label>
        <input
          value={content}
          onChange={(e) => setContent(e.target.value)}
          className="border"
          required
        />
      </div>
      <div>
        <button
          type="submit"
          className="px-2 py-1 bg-blue-500 text-white rounded-lg"
        >
          Submit
        </button>
      </div>
    </form>
  );
}

作成後、page.tsxファイルでimportを行います。


import { Post } from '@prisma/client';
import AddPost from './AddPost';

const Page = async () => {
  const response = await fetch('http://localhost:3000/api/posts');
  const posts: Post[] = await response.json();

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">記事一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
      <AddPost />
    </div>
  );
};

export default Page;

送信されてきたPOSTリクエストを受け取りデータベースにデータ登録できるようにRoute Handlersの設定も追加します。更新するファイルはapp/api/posts/route.tsです。


import { NextResponse } from 'next/server';
import prisma from '@/lib/prisma';

export async function GET() {
  const posts = await prisma.post.findMany();
  return NextResponse.json(posts);
}

export async function POST(request: Request) {
  const req = await request.json();
  await prisma.post.create({ data: req });

  return NextResponse.json(req);
}

Route Handlersの設定完了後、ブラウザから/postsにアクセスを行うとuseState Hookを利用しているのでエラーメッセージが表示されます。”You’re importing a component that needs useState. It only works in a Client Component but none of its parents are marked with “use client”, so they’re Server Components by default.”。デフォルトではappディレクトリ以下のコンポーネントはServer Componentとして動作するためuseState Hookを利用することができません。Client Componentとして動作させるためファイルの先頭に’use client’を追加します。


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

export default function AddPost() {
//略

‘use client’を追加するとエラーは解消されるので記事一覧とフォームが表示されます。

入力フォームの表示
入力フォームの表示

titleとcontentに文字列を入力して”Submit”ボタンをクリックしてください。”Submit”ボタンをクリックしても入力したデータは表示されません。Prisma Studioを利用してデータが登録されているか確認するとPostテーブルには入力したデータが登録されていることが確認できます。

ページのリロードを行っても新しいデータが表示されることはありません。理由はfetch関数のオプションのcacheの値を”no-store”に変更していないためキャッシュに保存されたデータが表示されます。開発サーバを起動したターミナルにも”cache: HIT”が確認できます。

page.tsxファイルでfetch関数のオプションにcache:’no-store’を追加します。


const response = await fetch('http://localhost:3000/api/posts', {
  cache: 'no-store',
});

no-storeを設定すると”cache: MISS”になるためキャッシュではないデータの再取得が行われます。

no-storeを設定することでデータが取得できる
no-storeを設定することでデータが取得できる

この状態で再度データの登録を行います。入力フォームに文字列を入力して”Submit”ボタンをクリックします。しかし画面には入力したデータは表示されません。Prisma Studioを確認すると問題なくデータは登録されています。ページをリロードすると入力したデータが再表示されるようになります。

“Submit”ボタンを押してもページのリフレッシュが行われないためデータの再取得が行われないことが原因です。登録が完了した後にページのリフレッシュが行えるようにuseRouterのrefreshメソッドを利用します。useRouterはnext/navigationからimportします。


'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function AddPost() {
  const router = useRouter();

  const [title, setTitle] = useState<string>('');
  const [content, setContent] = useState<string>('');
  const handleSubmit: React.FormEventHandler<HTMLFormElement> = async (
    event
  ) => {
    event.preventDefault();

    await fetch('http://localhost:3000/api/posts/', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title, content }),
    });

    setTitle('');
    setContent('');

    router.refresh();
  };
//略

useRouter.refreshを追加後に入力フォームから入力後に”submit”ボタンをクリックすると入力したデータが自動で反映されます。

App Routerの環境でのデータの登録方法を確認できました。

Optimizing

Metadata の設定

headタグに中に挿入するtitleやdescriptionなどのMetaタグを設定する方法にはStaticとDynamicな2つの設定方法が提供されています。設定はlayout.tsまたはpage.tsファイルで行います。

staticな設定方法はappディレクトリのlayout.tsxファイルにデフォルトから設定されています。


export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

この設定が行われているため/usersにアクセスしてもブラウザのタブに設定したtitleが表示されています。

ブラウザのタブに表示されているtitleの確認
ブラウザのタブに表示されているtitleの確認

Static Metadataの設定

ユーザページに表示されるtitleを変更したい場合には下記のように設定を行うことができます。


export const metadata = {
  title: 'ユーザの一覧ページ',
  description: 'JSONPlaceHolderから取得したユーザ一覧です。',
};

設定した値がブラウザのタブに反映されます。descriptionについてはページのソースで確認できます。

page.tsxファイルでtitleとdescriptionの設定
page.tsxファイルでtitleとdescriptionの設定

Dynamic Metadataの設定

Dynamic Metadataを設定するためユーザ一覧に表示したユーザ名をクリックすると詳細ページに移動できるようにDynamic Routesを利用して設定を行います。リンクにはLinkコンポーネントを利用しています。


import UserList from './UserList';

const Page = async () => {
  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ一覧</h1>
      {/* @ts-expect-error Async Server Component */}
      <UserList />
    </div>
  );
};

export default Page;

import Link from 'next/link';

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

const UserList = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>
          <Link href={`/users/${user.id}`}>{user.name}</Link>
        </li>
      ))}
    </ul>
  );
};

export default UserList;

現在の設定ではユーザ名をクリックするとDynamic Routesの設定を行なっていないので404ページが表示されます。

Dynamic Routesを設定するためusersディレクトリに[id]ディレクトリを作成してその下にpage.tsxファイルを作成します。


import { type User } from '../UserList';

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

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ詳細</h1>
      <p>ID: {user.id}</p>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
};

export default Page;

設定後、ユーザ名をクリックするとユーザの詳細画面が表示されます。

ユーザの詳細画面
ユーザの詳細画面

titleにユーザ名を設定したい場合はページの内容が動的に変わるためStaticな方法でMetadataを設定することができません。そのためgenerateMetadata関数を利用してDynamicにMetadataを設定します。

titleを設定する際にもfetch関数を利用してデータを利用するためgetUser関数を作成しています。画面に表示されるユーザ情報とMetadataに利用するユーザ情報のため1つのPageコンポーネントの中で同じURLに対してfetchの処理を行っていますがAutomatic Request Dedupingにより同じURLへのリクエストは最適化されます。


import type { Metadata } from 'next';
import { type User } from '../UserList';

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

export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const user = await getUser(params.id);
  return { title: user.name };
}

const Page = async ({ params }: { params: { id: string } }) => {
  const user: User = await getUser(params.id);

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ詳細</h1>
      <p>ID: {user.id}</p>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
};

export default Page;

ブラウザから確認するとDynamicにtitleが設定されていることがわかります。

Dynamicに設定を行ったtitleの確認
Dynamicに設定を行ったtitleの確認

全ページのタイトルにアプリケーションの名前等(XXXX | Next App)を設定したい場合があります。その場合はlayout.tsxファイルのmetadaの設定でtemplateを利用することができます。


export const metadata = {
  title: {
    default: 'Create Next App',
    template: `%s | Next App`,
  },
  description: 'Generated by create next app',
};

ブラウザで確認するとページのタイトルの後に”| App”が追加されています。

タイトルに | Next. Appを追加
タイトルに | Next. Appを追加

faviconの設定

デフォルトからappディレクトリの下にfavicon.icoファイルが存在していますがlayout.tsxでもfavicon.icoの設定を行っていません。appディレクトリの下にfavicon.icoを配置するだけで自動でheadタグの中に追加されます。もしfavicon.icoの名前を変更したり削除するとタグは消えます。


<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="any">

opengraph-imageの設定

OGPの画像を設定したい場合はopengraph-imageというファイル名でサポートされている.jpg, .jpeg, .png, .gifの拡張子を持つファイルをappディレクトリに設定するだけで自動で設定を行ってくれます。ここではopengraph-image.pngファイルをappディレクトリに保存しました。下記のコードがheadタグに追加されます。


<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image" content="http://localhost:3000/opengraph-image.png?6462ecdf1a65b6d2">

appディレクトリにopengraph-image.pngファイルを保存すると/usersに上記のタグがheadの追加されます。opengraph-image.pngファイルをappディレクトリからusersディレクトリに移動すると/(ルート)にアクセスしてもOGP画像のmetaタグは設定されませんが/usersではmetaタグが表示されます。

動的なOGP画像の作成

/users/1にアクセスした場合に動的にOGPの画像を作成することができます。/users/[id]ディレクトリにopengrap-image.tsファイルを作成した以下のコードを設定します。


import { ImageResponse } from 'next/server';

export const size = {
  width: 1200,
  height: 630,
};
export const contentType = 'image/png';

export default async function Image({ params }: { params: { id: string } }) {
  const user = await fetch(
    `https://jsonplaceholder.typicode.com/users/${params.id}`
  ).then((res) => res.json());

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 48,
          background: 'aqua',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        {user.name}
      </div>
    ),
    {
      ...size,
    }
  );
}

ブラウザのデベロッパーツールの要素を利用してog:imageのmetaタグを見つけます。


<meta property="og:image" content="http://localhost:3000/users/1/opengraph-image?eebd5a17f61ce462">

このURLをブラウザのURLのバーに貼り付けて確認します。以下のようにOGP画像が動的に作成されることが確認できます。

Dynamicに作成されるOGP画像
Dynamicに作成されるOGP画像

Sitemap

サイトマップを設定したい場合にはappディレクトリの下にsitemap.xmlファイルを保存します。sitemap.xmlファイルの内容はNext.jsのドキュメントからコピーしています。


<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://acme.com</loc>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
  <url>
    <loc>https://acme.com/about</loc>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
  <url>
    <loc>https://acme.com/blog</loc>
    <lastmod>2023-04-06T15:02:24.021Z</lastmod>
  </url>
  </urlset>

http://localhost:300/sitemap.xmlにアクセスするとappディレクトリに保存したsitemap.xmlファイルの内容が表示されます。

ブラウザからsitemap.xmlの確認
ブラウザからsitemap.xmlの確認

静的にsitemap.xmlを作成しましたが動的に作成することができます。appディレクトリにsitemap.tsファイルを作成して以下のコードを記述します。


import { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  return [
    {
      url: 'https://acme.com',
      lastModified: new Date(),
    },
    {
      url: 'https://acme.com/about',
      lastModified: new Date(),
    },
    {
      url: 'https://acme.com/blog',
      lastModified: new Date(),
    },
  ];
}

ブラウザからhttp://localhost:3000/sitemap.xmlにアクセスするとnew Date()を設定しているのでアクセスした日が表示されます。


<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <url>
    <loc>https://acme.com</loc>
    <lastmod>2023-05-10T06:48:37.849Z</lastmod>
  </url>
  <url>
     <loc>https://acme.com/about</loc>
     <lastmod>2023-05-10T06:48:37.849Z</lastmod>
  </url>
  <url>
    <loc>https://acme.com/blog</loc>
    <lastmod>2023-05-10T06:48:37.849Z</lastmod>
   </url>
</urlset>

通常はデータベースまたはリモートサーバからページ情報を取得するためここではJSONPlaceHolderを利用して取得したデータを利用してsitemapを作成します。


import { MetadataRoute } from 'next';

type User = {
  id: string;
};

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  const users: User[] = await response.json();

  const usersUrl = users.map((user) => {
    return {
      url: `http://localhost:3000/users/${user.id}`,
      lastModified: new Date(),
    };
  });

  return [
    {
      url: 'http://localhost:3000',
      lastModified: new Date(),
    },
    {
      url: 'http://localhost:3000/users',
      lastModified: new Date(),
    },
    ...usersUrl,
  ];
}

ブラウザからsitemap.xmlにアクセスすると以下のように表示されます。

リモートサーバから取得したデータでsitemap.xmlを作成
リモートサーバから取得したデータでsitemap.xmlを作成

robots.txt

robots.txtはクロール可能なページを検索エンジンからのクローラーに対して伝えるファイルです。

appディレクトリにrobots.txtファイルを作成して保存します。/privateディレクトリに対してのみクロールを拒否する設定を行っています。User-Agentではすべてのクローラーに設定を伝えています。


User-Agent: *
Allow: /
Disallow: /private/
Sitemap: http://localhost:3000/sitemap.xml

ファイルをappディレクトリに保存後にhttp://localhost:3000/robots.txtにアクセスするとrobots.txtに記述した内容が表示されます。

robots.txtではなくrobots.tsファイルでも設定を行うことができます。


export default function robots(): MetadataRoute.Robots {
  return {
    rules: {
      userAgent: '*',
      allow: '/',
      disallow: '/private/',
    },
    sitemap: 'http://localhost:3000/sitemap.xml',
  };
}

http://localhost:3000/robots.txtにアクセスするとrobots.tsで設定した内容が表示されます。

もしrobots.txt, robots.tsファイルがappディレクトリに存在する場合は”Duplicate page detected. app/robots.ts and app/robots.txt resolve to /robots.txt”のメッセージがnpm run devコマンドを実行しているターミナルに表示されます。

canonical

複数のページで同じコンテンツが表示される場合に検索エンジンにオリジナルコンテンツのURLを伝える必要があります。またhttp://www.localhost:3000、http://localhost:3000ののようにwwwあり、なしどちらもでもアクセス可能な場合に重複したコンテンツではないようにどちらかをcanonicalで設定します。

appディレクトリのpage.tsxファイルで設定を行います。metaBaseを設定することでcanonicalに”/”と設定しても自動でhttp://localhost:3000/となります。


export const metadata = {
  metadataBase: new URL('https://localhost.com:3000'),
  alternates: {
    canonical: '/',
  },
};

ブラウザで確認すると以下のタグが設定されます。


<link rel="canonical" href="https://localhost.com:3000/">

metadataBaseを設定していない場合の動作確認を行います。


export const metadata = {
  // metadataBase: new URL('https://localhost.com:3000'),
  alternates: {
    canonical: '/',
  },

hrefの値が”/”となるためmetadataBaseを利用していない場合はcanonicalにフルパスのURLを設定する必要があります。


<link rel="canonical" href="/">

Fontの設定

next/fontを利用することでFontの最適化を行ってくれます。Google Fontを利用することができ、デフォルトでもGoogle FontのInterが設定されています。

設定はlayout.tsxファイルで行っています。


import './globals.css';
import { Inter } from 'next/font/google';

const inter = Inter({ subsets: ['latin'] });

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <div>{children}</div>
      </body>
    </html>
  );
}

別のフォントに変更したい場合にも簡単に行うことできます。フォントが変更したことがすぐにわかるようにDancing Scriptに変更してみましょう。


import './globals.css';
import { Dancing_Script } from 'next/font/google';

const dancingscript = Dancing_Script({ subsets: ['latin'] });

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className={dancingscript.className}>
        <div>{children}</div>
      </body>
    </html>
  );
}

ブラウザで確認するとフォントが変わっていることがわかります。

Dancing_Scriptフォント
Dancing_Scriptフォント

フォントはページのソースを見ると下記のlinkタグで設定が行われています。拡張子がwoff2のフォントファイルで_next/static/media/に保存されています。ローカルへフォントファイルがダウンロードされることもわかります。


<link rel="preload" as="font" href="/_next/static/media/e5f193da326e76b4-s.p.woff2" crossorigin="" type="font/woff2"/>

ビルド

npm run buildコマンドを実行すると本番環境用のビルドを行うことができます。ビルドを行うことによってファイルサイズや作成されるファイルだけではなくApp Routerで設定した各ルーティングがランタイムにサーバサイドでレンダリングするのかStatic HTMLとしてビルド時に作成されるのか確認することができます。


 % npm run build

> next-js-13@0.1.0 build
> next build

- warn Detected next.config.js, no exported configuration found. https://nextjs.org/docs/messages/empty-configuration
- info Creating an optimized production build  
- info Compiled successfully
- info Linting and checking validity of types  
- info Collecting page data  
- info Generating static pages (7/7)
- info Finalizing page optimization  

Route (app)                                Size     First Load JS
┌ ○ /                                      872 B          83.3 kB
├ ○ /about                                 178 B          82.6 kB
├ λ /api                                   0 B                0 B
├ λ /api/[id]                              0 B                0 B
├ ○ /favicon.ico                           0 B                0 B
├ ○ /users                                 178 B          82.6 kB
└ λ /users/[id]                            145 B            77 kB
+ First Load JS shared by all              76.8 kB
  ├ chunks/139-336052c7d3b6586e.js         24.4 kB
  ├ chunks/2443530c-1b4abb6ebb1db3b4.js    50.5 kB
  ├ chunks/main-app-60a08b1b1e2d662d.js    211 B
  └ chunks/webpack-4449db1b67bf02a4.js     1.64 kB

Route (pages)                              Size     First Load JS
─ ○ /404                                   178 B          85.9 kB
+ First Load JS shared by all              85.8 kB
  ├ chunks/main-8b170562f4103bba.js        83.9 kB
  ├ chunks/pages/_app-c544d6df833bfd4a.js  192 B
  └ chunks/webpack-4449db1b67bf02a4.js     1.64 kB

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

ルーティングの左に表示されているλマークかoかによってServer Side RenderなのかStaticなのかがわかります。aboutやusersなどはStaticですが、Dynamic Routesを利用している/users/[id]はSever Side Renderとなっています。

ビルド後.next/server/appディレクトリを確認するとStaticと表示されたaboutやusersについてはabout.html、users.htmlとHTMLファイルの形として保存されています。

users.htmlファイルの中身を確認するとユーザ情報を含んだHTMLであることがわかります。fetch関数で取得したユーザ名なども確認することができます。

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

cacheオプションの設定

Staticとして静的ファイルを作成するかどうかはfetch関数のcacheオプションの値にも関係しておりusers/UserList.tsxファイルでcacheの値を’no-store’にしてビルドを実行してみます。


const response = await fetch('https://jsonplaceholder.typicode.com/users', {
  cache: 'no-store',
});

cacheの値を’no-store’にする前は/usersは○からλに変わっていることがわかります。


 % npm run build
//略
Route (app)                                Size     First Load JS
┌ λ /                                      872 B          83.3 kB
├ ○ /about                                 178 B          82.6 kB
├ λ /api                                   0 B                0 B
├ λ /api/[id]                              0 B                0 B
├ ○ /favicon.ico                           0 B                0 B
├ λ /users                                 178 B          82.6 kB
└ λ /users/[id]                            145 B            77 kB

.next/server/appディレクトリを確認してもusers.htmlは作成されていません。

generateStaticParams

/user/[id]はビルドを作成するとλとなり、静的ファイルではなくリクエストが来た時にサーバサイドレンダリングによりページが作成されます。しかし、/user/[id]のidは動的に変わりますが同じidでアクセスがあれば同じ内容が表示されます。

generateStaticParams関数を利用することでDynamic Routesもビルド時に静的なファイルとして作成することができます。generateStaticParamsを利用してidの値をusers/[id]/page.tsxファイルでNext.jsに教えてあげる必要があります。


import type { Metadata } from 'next';
import { type User } from '../UserList';

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

export async function generateMetadata({
  params,
}: {
  params: { id: string };
}): Promise<Metadata> {
  const user = await getUser(params.id);
  return { title: user.name };
}

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(),
  }));
}

const Page = async ({ params }: { params: { id: string } }) => {
  const user: User = await getUser(params.id);

  return (
    <div className="m-4">
      <h1 className="text-lg font-bold">ユーザ詳細</h1>
      <p>ID: {user.id}</p>
      <p>Name: {user.name}</p>
      <p>Email: {user.email}</p>
    </div>
  );
};

export default Page;

generateStaticParams関数を設定後、npm run buildコマンドでビルドを行います。ビルドのメッセージを見るとこれまでの○,λとは異なる●となっていることがわかります。


 % npm run build
//略
Route (app)                                Size     First Load JS
┌ λ /                                      872 B          83.3 kB
├ ○ /about                                 178 B          82.6 kB
├ λ /api                                   0 B                0 B
├ λ /api/[id]                              0 B                0 B
├ ○ /favicon.ico                           0 B                0 B
├ λ /users                                 178 B          82.6 kB
└ ● /users/[id]                            145 B            77 kB
    ├ /users/1
    ├ /users/2
    ├ /users/3
    └ [+7 more paths]
//略
λ  (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)

●はSSG(Sever Site Generator)の略で静的なファイルが作成されます。

.next/server/appのusersディレクトリを確認すると1.html, …., 10.htmlまでHTMLファイルが作成されていることがわかります。このようにgenerateStaticParamsを利用することでDyamic Routesから静的なファイルを作成することができます。

Dynamic Routesの一部のidだけ静的ファイルを作成したい場合にはgenerateMetadata関数で静的なファイルを作成する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(),
  }));
}

ビルドを行うと指定した数のみ静的ファイルが作成されていることがわかります。.next/server/appのusersディレクトリを確認すると1.html, 2.html, 3.htmlのファイルのみ作成されています。


├ λ /users                                 178 B          82.6 kB
└ ● /users/[id]                            145 B            77 kB
    ├ /users/1
    ├ /users/2
    └ /users/3
+ First Load JS shared by all 

関数を利用せず下記のように記述することもできます。指定した/users/1, /users/4, /users/8のみ静的ファイルが作成されます。


export function generateStaticParams() {
  return [{ id: '1' }, { id: '4' }, { id: '8' }];
}

Revalidating Data

npm run buildコマンドで静的ファイルを作成することができましたが現在の設定ではビルドコマンド実行時に取得したデータが更新された場合再度ビルドを行うまでページに反映されません。

アプリケーション全体を再ビルドすることなく更新した内容を反映させるためにfetch関数のnext.revalidateを利用します。指定した時間が経過するとバックグランドでページの更新を行ってくれます。

設定方法はfetchingの章で確認済みですがuser/[id]/page.tsxファイルで以下の設定を行います。


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

ページの更新が本当に行われているかどうか確認するためにrandamメソッドを利用し乱数をブラウザ上に表示させこの値が更新されるかどうかチェックを行います。next.revalidateで設定した時間が経過して値が更新されればページの更新が行われていることがわかります。


<div className="m-4">
  <h1 className="text-lg font-bold">ユーザ詳細</h1>
  <p>ID: {user.id}</p>
  <p>Name: {user.name}</p>
  <p>Email: {user.email}</p>
  <p>Randam: {Math.random()}</p>
</div>

設定後はnpm run buildコマンドを実行します。

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

.next/server/app/usersディレクトリに保存されているhtmlの内容を確認していると設定した時間を経過するとファイルが再作成されていることがわかります。

このように静的ファイルもfetch関数のnext.revalidateを利用することで更新を行うことができます。

現在の引き続き更新中です。