新しいNext.jsの入門 ─ App DirectoryによるWeb開発をハンズオンで理解しよう

Next.jsは、ReactベースのWebアプリケーションフレームワークで、SSR(Server Side Rendering)などフロントエンド開発に必要な機能を十分に備えています。2022年10月には、App Directoryという新しい概念をβ版として導入したバージョン13がリリースされました。この基本的な考え方と使い方をハンズオン形式で解説します。株式会社アンドパッドでソフトウェアエンジニアを務める村田司(Tim0401)さんよる執筆です。

新しいNext.jsの入門 ─ App DirectoryによるWeb開発をハンズオンで理解しよう

株式会社アンドパッドでソフトウェアエンジニアをしている村田Tim0401です。

アンドパッドでは「幸せを築く人を、幸せに。」をミッションに、現場の効率化から経営改善まで一元管理できるクラウド型建設プロジェクト管理サービス「ANDPAD」を提供しています。「ANDPAD」は利用企業社数が15.6万社(2023年1月時点)、41.3万人以上の建設・建築関係者に利用されるシェアNo.1サービス1に成長しており、業界のプラットフォーマーとなるべく、新規プロダクトや新機能の開発を進めています。

そんなアンドパッドにはVue.js+Nuxtベースのプロダクトと、React+Next.jsベースのプロダクトがあり、私の所属するチームではNext.jsを利用しています。

本稿では、簡単なノートアプリの開発を通して、2022年10月にリリースされたNext.js 13の新機能を中心に、Next.jsの基本をハンズオン形式で紹介していきます。Next.jsを初めて利用する方でも分かりやすい構成ですので、ぜひ最後まで一緒に実装してみてください。

Next.jsの紹介と新バージョン

Next.jsは、Reactを用いたWebアプリケーションフレームワークであり、フロントエンド開発に広く使用されています。SSR(Server Side Rendering)やファイルシステムベースのルーティング(file-system based routing)、APIの構築など、Web開発に必要な機能をフルスタックで提供していることが特徴です。

Next.js by Vercel - The React Framework

2022年10月にNext.js 13がリリースされ、App Directoryappディレクトリ)あるいはApp Routerと呼ばれる新しい概念がβ版として追加されました。

Getting Started | Next.js - the docs for the App Router (beta)

本稿では、まずNext.js 13の新機能について、App Directoryに重点を置いて解説します。その後、新機能を用いながら簡単なノートアプリを開発します。最後に、解説し切れなかった細かな挙動をいくつかまとめて紹介します。

事前知識として、ReactやTypeScriptの基本的な使い方を知っていると読み進めやすいでしょう。ですが、そういった技術に触れたことがなくても、アプリの開発を通してNext.jsの機能が学べるようになっています。

なお、Next.js 13のリリース後、マイナーバージョンも2度リリースされています13.113.2が、本稿で用いるバージョンは、Next.js 13.2.1です。β版の機能を含んでいるため、安定版がリリースされるまでに大幅に変更される可能性があります。

新機能「App Directory」はどのように使うのか?

本章と次章ではNext.js 13の新機能の一部について、App Directoryを中心に解説します。App Directoryは、これまでのpagesディレクトリベースの開発を将来的に置き換えるであろう新しい機能です。

本稿を執筆している2023年3月時点ではβ版としてリリースされており、本番環境での使用は推奨されていません。実装予定の機能は、公開されているロードマップで見ることができます。

App Directoryの始め方

ドキュメントの「Installation」に記載されている通り、App Directoryを用いたNext.jsプロジェクトは以下のコマンドで作成できます。

$ npx create-next-app@latest --experimental-app

これを実行すると、appディレクトリ配下に以下のようなファイルが生成されます。

app
|-- api
|   `-- hello
|       `-- route.ts  # Route Handlers(API)(`/api/hello`)
|-- favicon.ico
|-- globals.css
|-- layout.tsx # レイアウト(`/`)
|-- page.module.css
`-- page.tsx # ルーティング(`/`)

ルーティング

App Directoryでも、以前までと同様にファイルシステムベースのルーティングが採用されています。それぞれの階層で、page.tsxが表示されるページとなります。

app
|-- samples
|   |-- page.tsx # `/samples`
|   `-- [id]
|       `-- page.tsx #`/samples/:id`
`-- page.tsx # `/`

appディレクトリ直下のpage.tsxはルートページ(/)に対応しています。同様にapp/samplesディレクトリ以下にpage.tsxを配置すると、/samplesに対応するページが作成できます。パスパラメーターやクエリパラメーターにも対応しており、app/samples/[id]/page.tsxを作成すると、/samples/:idに対応するページになります。

app以下のディレクトリには、このpage.tsxや後に説明するlayout.tsxおよびroute.ts以外にも、任意のファイルを配置可能です。特定のページにのみ使用するコンポーネントやフック、テストファイルなどを、page.tsxと同じディレクトリに置くことができます。そのためコロケーションと呼ばれる構造が実現しやすくなっています。

コロケーションについてより詳しく知りたいなら、Kent C. Doddsによる次の記事も参考にしてください。

Colocation - The Kent C. Dodds Blog

レイアウト

layout.tsxは、その階層以下で表示される共通のレイアウトとなります。

app
|-- samples
|   |-- layout.tsx # `/samples`以下の共通レイアウト
|   |-- page.tsx # `/samples`
|   `-- [id]
|       `-- page.tsx #`/samples/:id`
|-- layout.tsx # `/`以下の共通レイアウト
`-- page.tsx # `/`

appディレクトリ直下のlayout.tsxは特殊でRootLayoutと呼ばれ、次のように<html><body>タグを含む必要があります。これは従来あった_document.tsxの代替となります。

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

それ以外のlayout.tsxは、置かれたディレクトリ以下のページの共通レイアウトとなります。例えば次のようなlayout.tsxが、app/samplesディレクトリにあったとします。

export default function Layout({
  children,
}: {
  children: React.ReactNode,
}) {
  return (
    <section>
      {/* Include shared UI here e.g. a header or sidebar */}
      {children}
    </section>
  );
}

上記の例において、ルートページ(/)ではapp/layout.tsxが、/samples以下ではapp/layout.tsxとその下にネストされた形でapp/samples/layout.tsxが使用されます。

結果として/samplesでのHTMLの構造は以下のようになります。

<!-- app/layout.tsx は一番外側 -->
<html lang="en">
  <body>
    <!-- app/samples/layout.tsx は app/layout.tsx にラップされている -->
    <section>
      <!-- app/samples/page.tsx がここに表示される -->
    </section>
  </body>
</html>

layout.tsxにおいても、page.tsxと同様にパスパラメーターとクエリパラメーターが使用可能です。

また、レイアウトが共通なルート間の遷移では、クライアントサイドでのキャッシュを用いて共通部分を再利用することで、不要なレンダリングが回避されます。

ヘッド部におけるメタデータの変更

App Directoryを使う際に、ヘッド部の変更方法を紹介します。これまで_document.tsx等で設定していた<head>の変更は、page.tsxlayout.tsxで行うことができます。

export const metadata = {
  title: 'Next.js'
};

また、パスパラメーターとクエリパラメーターを用いたデータフェッチで、動的にメタデータを生成することもできます。

export async function generateMetadata({ params, searchParams }) {
  // `/sample/123`では、`params.id` が "123" となる
  // `/sample/123?foo=bar`では、`searchParams.get("foo")` が "bar" となる
  const sample = await fetchSample(params.id);
  // 戻り値はメタデータのオブジェクト
  return { title: sample.name };
}

この機能により、メタデータをルートによって柔軟に変更しやすくなります。

Route Handlers(API)

route.tsAPIのハンドラーであり、API Routesの代替となります。

app
`-- api
    `-- hello
        |-- route.ts  # `/api/hello`
        `-- [id]
            `-- route.ts  # `/api/hello/:id`

ファイルシステムベースのルーティングとなっており、app/api/hello/route.ts/api/helloに対応します。

export async function GET(request: Request) {
  return new Response('Hello, Next.js!')
}

route.tsGETPOSTPUTPATCHDELETEなどの関数をエクスポートすることで、それぞれのHTTPメソッドに対応するハンドラーとなります。また、他と同様にパスパラメーターが使用可能です。以下のように特定のリソースに対する操作をまとめて定義できます。

// 取得
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  // ...
  return new NextResponse('Hello, Next.js!')
}
// 更新
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
  // ...
  return new NextResponse(null, { status: 204 })
}
// 削除
export async function DELETE(_req: NextRequest, { params }: { params: { id: string } }) {
  // ...
  return new NextResponse(null, { status: 204 })
}

まとめると次のようになります。

HTTPメソッド URL 呼び出される関数
GET /hello app/api/hello/route.ts:GET()
GET /hello/:id app/api/hello/[id]/route.ts:GET()
PUT /hello/:id app/api/hello/[id]/route.ts:PUT()
DELETE /hello/:id app/api/hello/[id]/route.ts:DELETE()

制約として、route.tspage.tsxと同じディレクトリには配置できません。app/api以下のディレクトリ以外にroute.tsを置くこともできますが、page.tsxとのコンフリクトに注意しましょう。

レンダリング

ページのレンダリングにおいて、Server ComponentsとClient Componentsという2種類のコンポーネントがあります。

Server Components

サーバーサイドでのみレンダリングされるコンポーネントです。デフォルトではこちらが使用されます。パフォーマンスに優れている、バックエンドのリソース(データベースや環境変数など)に直接アクセスできる、といった利点があります。サーバーサイドでのみ動作するため、それらの利点と引き換えにフックやブラウザーのAPIが使用できないなどの制限があります。

Client Components

今までのコンポーネントと同様です。主にフックを用いてアプリケーションの状態を管理したい場合に使用します。'use client'をファイル内に記述することで、Client Componentsとして扱われます。SSRによるサーバーサイドのレンダリングと、クライアントサイドでのレンダリングが可能です。

Client Components内でServer Componentsを直接使用することはできません。なお、childrenなどのpropsを用いてServer Componentsを渡すことは可能です。

両者の具体的な使い分けについては、ドキュメントを参照してください。

データフェッチと再検証

Server Componentsでfetch APIを使用すると、Next.jsがリクエストの重複を排除して効率的にデータを取得します。これにより、コンポーネントの複数箇所で同じデータを取得する場合でも、1回のリクエストで済むようになります。この機能は、データを使用する末端コンポーネントそれぞれでデータフェッチする場合の重複を排除することに貢献します。

また、コンポーネントごとの静的・動的レンダリングの設定や、ISRにおける再検証のタイミングなども設定可能です。詳しくはドキュメントを参照してください。

その他の特徴

Next.js 13では、他にも次のような新機能が追加されました。

Turbopack

Turbopackは、webpackの後継となることが期待されているバンドラーです。まだα版ですが、ドキュメントにはNext.jsのプロジェクトでの導入方法が記載されています。

本稿では使用しませんが、より高速な開発体験を得たい場合は試してみると良いでしょう。

Next.js 13にて、<Link>コンポーネント内に<a>タグを含めることが不要になりました。

また、Next.js 13.2では、Statically Typed Linksがβ版で追加されています。これは<Link>コンポーネントのhrefにおいて、存在するページへのリンクを静的に強制できます。

これまではタイプミスなどで、存在しないページへのリンクを配置してしまうこともありましたが、この機能によりコンパイル時にエラーを検知することが可能になりました。

next/font

Next.jsで、フォントをホストできるようになりました。フォント読み込みの最適化が期待できます。

import { Inter } from 'next/font/google'

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

export default function MyApp({ Component, pageProps }) {
  return (
    <main className={inter.className}>
      <Component {...pageProps} />
    </main>
  )
}

ハンズオンで解説するWebアプリケーションの概要

ここから簡単なノートアプリを作成して、Next.jsの機能に触れていきましょう。実装するソースコードは、以下のGitHubリポジトリにあります。完成したデモ動画もありますのであわせてご覧ください。

Tim0401/nextjs-app-directory-demo

次のようにライブラリのインストールとデータベースのセットアップだけで動作するのでご活用ください。

$ npm install
$ npx prisma migrate dev
$ npx prisma db seed
$ npm run dev

なお、上記のリポジトリには完成したコードだけでなく、以下の各節で説明する時点の実装に即したブランチも含まれています。関連ブランチへのリンクを冒頭で記載していますので、ぜひ参照してください。

作成する画面一覧

このアプリでは、以下の画面とそれにまつわる機能を実装します。

URL 画面名
/ TOP
/note ノート一覧
/note/new ノート追加
/note/:id ノート詳細/削除
/note/:id/edit ノート編集
/settings 設定
/help/faq FAQ
/help/tos 利用規約

使用するライブラリ

このアプリで、Next.js以外に使用するライブラリは以下の4つです。インストール方法等は必要な箇所で説明します。

Prisma

TypeScriptのORマッパーです。アプリでのノートの保存等に使用します。

Prisma | Next-generation ORM for Node.js & TypeScript

Tailwind CSS

CSSフレームワークです。アプリのUIデザインに使用します。

Tailwind CSS - Rapidly build modern websites without ever leaving your HTML.

Zod

バリデーションライブラリです。APIレスポンスの型定義とバリデーションに使用します。

Zod | Documentation

SWR

データフェッチ用のライブラリです。ノート一覧のクライアントサイドでの取得に使用します。

React Hooks for Data Fetching – SWR

開発環境

本稿の開発環境は以下の通りです。OSなどの指定はありません。

$ node -v
v18.14.2

なお筆者は、macOS 13.2上にて、Dev Containerを用いて開発・動作確認しています。またエディターとしてVisual Studio Codeを使用する場合は、以下の拡張機能を有効にすることを推奨します。

  • dbaeumer.vscode-eslint
  • bradlc.vscode-tailwindcss
  • csstools.postcss
  • Prisma.prisma

では始めていきましょう。

create-next-appから始める

まず、前述したドキュメントに従ってNext.jsプロジェクトを作成します。

Installation | Next.js

この節の内容を反映したブランチはcreate-next-appになります。

任意のディレクトリで、以下のコマンドを実行します。プロジェクト名は任意ですが、今回はnextjs-app-directory-demoとしました。他の選択肢はデフォルトのまま、入力せずにEnterで進めていきます。

$ npx create-next-app@latest --experimental-app
Need to install the following packages:
  create-next-app@13.2.1
Ok to proceed? (y) y
# プロジェクト名を入力(デフォルト:my-app)
✔ What is your project named? … nextjs-app-directory-demo
# TypeScriptを使用するか(デフォルト:Yes)
✔ Would you like to use TypeScript with this project? … No / Yes
# ESLintを使用するか(デフォルト:Yes)
✔ Would you like to use ESLint with this project? … No / Yes
# srcディレクトリを使用するか(デフォルト:No)
✔ Would you like to use `src/` directory with this project? … No / Yes
# インポートエイリアスの記号(デフォルト:@)
✔ What import alias would you like configured? … @/*
Creating a new Next.js app in ${コマンドを実行したディレクトリ}/nextjs-app-directory-demo.

Using npm.

Initializing project with template: app

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

プロジェクト名のディレクトリが作成され、その中にNext.jsプロジェクトのファイルが作成されています。

起動

アプリ名のディレクトリ直下にて、アプリを起動します。

$ npm run dev

ここでlocalhost:3000にアクセスすると、左上にGet started by editing app/page.tsxと書かれたページが表示されます。

サンプルファイル

作成されたファイルを見てみましょう。appディレクトリ以下に、layout.tsxpage.tsxがデフォルトで作成されます。app/api/hello以下には、Route Handlersのサンプルとしてroute.tsが作成されています。

各ファイルの詳細は、上記App Directoryの解説を参照してください。

サンプルファイルの削除と修正

動作を確認したら、アプリを作成する上で不要なファイルを削除します。

この節の内容を反映したブランチはremove-initial-filesになります。

appディレクトリのglobals.csspage.module.cssapi/helloディレクトリ、publicディレクトリのthirteen.svgvercel.svgを削除します。

また、ルーティングとレイアウトを修正します。app/page.tsxは以下のように書き換えます。

// 1. ページの表示内容
export default function Page() {
  return (
    <main>
      <div>
        <p>Hello, world!</p>
      </div>
    </main>
  )
}

このファイルで説明したい箇所は次の1つです。

  1. ページで表示したい内容を記述します。
    今回はシンプルにHello, world!と表示させます。

app/layout.tsxも以下のように書き換えます。

// 1. ページのメタデータ
export const metadata = {
  title: 'Next.js Awesome Memo App',
  description: 'Generated by create next app',
}

// 2. ページのレイアウト
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ja">
      {/* 3. ページやレイアウトの内容を表示 */}
      <body>{children}</body>
    </html>
  )
}

このファイルで説明したい箇所は次の3つです。

  1. ページのメタデータを記述します。
    今回はページのタイトルをNext.js Awesome Memo Appとします。 app/layout.tsxは全ページに適用されるため、他のページでtitleが定義されない限り、この値が用いられます。
  2. ページのレイアウトを記述します。
    前述の通りapp/layout.tsxは全ページに適用されます。
  3. ページやレイアウトの内容を表示します。
    この場合、childrenにはapp/page.tsxで記述した内容が入ります。

ここでlocalhost:3000にアクセスすると、Hello, world!と表示されます。また、metadataの書き換えにより、ページのタイトルがNext.js Awesome Memo Appに変更されていることが確認できます。

next/fontの導入

今回、アプリケーションを通してNoto Sans JPを使用します。

この節の内容を反映したブランチはnext-fontになります。

これはNext.js 13の新機能であるnext/fontにて導入します。app/layout.tsxを書き換えてフォントを読み込みます。先頭に以下を追加します。

import { Noto_Sans_JP } from 'next/font/google'

// 1. フォントの読み込み
const NotoSansJP = Noto_Sans_JP({
  weight: ["400", "700"],
  subsets: ["latin"],
  preload: true,
});

上記で説明したい箇所は次の1つです。

  1. フォントを読み込みます。
    オプションはドキュメントにて確認できます。

また、<body>タグにclassNameを追加します。

<body className={NotoSansJP.className}>{children}</body>

ここでlocalhost:3000にアクセスすると、フォントにNoto Sans JPが適用されてHello, world!が表示されます。フォントがGoogleではなく、localhostから読み込まれていることも確認できるでしょう。

Tailwind CSSの導入

今回のアプリではUIデザインにTailwind CSSを使用します。

この節の内容を反映したブランチはinstall-tailwind-cssになります。

App Directoryでも動作しますので、ドキュメントにある通りに導入していきましょう。

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

これによってtailwind.config.jspostcss.config.jsが作成されます。tailwind.config.jsを以下のように書き換えます。

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    // 指定したファイルのみにtailwindcssが適用されるようにする
    "./app/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

次のような内容で、app/globals.cssを作成します。

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

このCSSをインポートする1行を、app/layout.tsxの先頭に追加します。

import './globals.css';

app/page.tsxを次のように修正して、Tailwind CSSが利用できることを確認します。

<p className="font-bold underline">Hello, world!</p>

Hello, world!の表示が太字になり、下線が引かれていることが確認できます。アプリ内でTailwind CSSが使用できるようになりました。

Prismaの導入

今回作成するアプリではノートやメタ情報を保存するためにSQLiteを使用します。また、TypeScriptからデータベースへアクセスするためにPrismaを導入します。

この章の内容を反映したブランチはinstall-prismaになります。

以下のコマンドを実行してPrismaをインストールします。

$ npm install prisma ts-node --save-dev
$ npx prisma init --datasource-provider sqlite

上記コマンドで作成されたprisma/schema.prismaに、以下を追記してスキーマを作成します。

// バージョン情報などのメタデータを格納するテーブル
model Metadata {
  id    Int    @id @default(autoincrement())
  key   String @unique
  value String

  @@map("metadata")
}

// ノートを格納するテーブル
model Note {
  id        Int      @id @default(autoincrement())
  title     String
  body      String
  // カラム名はsnake_case、TypeScriptのプロパティ名はcamelCase
  createdAt DateTime @default(now()) @map("created_at")
  updatedAt DateTime @default(now()) @map("updated_at")

  // テーブル名はnotes
  @@map("notes")
}

以下のコマンドでマイグレーションを実行します。

$ npx prisma migrate dev --name init

Prisma Studioを起動すると、スキーマが作成されていることが確認できます。

$ npx prisma studio

ここでlocalhost:5555にアクセスすると、以下の画面が表示されます。

next1

seedデータの作成

データベースにあらかじめseedデータを作成しておきます。次のようなprisma/seed.tsを作成します。

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

const prisma = new PrismaClient()

async function main() {
  // delete all
  await prisma.metadata.deleteMany();
  await prisma.note.deleteMany();
  // seeding
  const metadatas: Prisma.MetadataCreateInput[] = [
    {
      key: "version",
      value: "13.2.1",
    },
    {
      key: "faq",
      value: faq,
    },
    {
      key: "tos",
      value: tos,
    },
  ];
  for (const metadata of metadatas) {
    await prisma.metadata.create({
      data: metadata
    });
  }

  const notes: Prisma.NoteCreateInput[] = [
    {
      title: "First note",
      body: "This is the first note.",
    },
    {
      title: "Second note",
      body: "This is the second note.",
    },
    {
      title: "Third note",
      body: "This is the third note.",
    },
    {
      title: "Fourth note",
      body: "This is the fourth note.",
    },
  ];
  for (const note of notes) {
    await prisma.note.create({
      data: note
    })
  }
}

main()
  .then(async () => {
    await prisma.$disconnect()
  })
  .catch(async (e) => {
    console.error(e)
    await prisma.$disconnect()
    process.exit(1)
  });

const faq = `
Q: How do I create a new note?
A: To create a new note, click the "New Note" button located in the top left corner of the screen. This will open a blank note where you can begin typing.

Q: Can I customize the appearance of my notes?
A: Yes, you can customize the appearance of your notes by changing the font, font size, and background color. Simply click the "Settings" button and select "Appearance" to make these changes.

Q: Can I share my notes with others?
A: Yes, you can share your notes with others by clicking the "Share" button located at the bottom of the note. You can then enter the email address of the person you wish to share the note with.

Q: How do I delete a note?
A: To delete a note, click on the note you wish to delete and then click the "Delete" button located at the bottom of the note.

Q: Is my data secure?
A: Yes, we take the security and privacy of your data very seriously. All notes are stored on secure servers and are encrypted for added protection.

Q: Can I access my notes on multiple devices?
A: Yes, you can access your notes on multiple devices by logging into your account on our website. All notes will be synced across all devices.

Q: What happens if I forget my password?
A: If you forget your password, you can reset it by clicking the "Forgot Password" link located on the login page. You will then be prompted to enter your email address to receive instructions on how to reset your password.

Q: Do you offer a mobile app?
A: Yes, we offer a mobile app for both iOS and Android devices. You can download the app from the App Store or Google Play.
`
const tos = `
Welcome to our website. These Terms of Service ("TOS") govern your use of our website, including any content, functionality, and services offered on or through the website. By using our website, you accept and agree to be bound by these TOS. If you do not agree with these TOS, you may not use our website.

User Conduct
You agree to use our website only for lawful purposes and in a manner that does not violate the rights of any third party. You agree not to use our website in any way that could damage, disable, overburden, or impair our servers or networks. You also agree not to access or attempt to access any information or data on our website that you are not authorized to access.

Intellectual Property
All content on our website, including text, graphics, logos, images, and software, is owned by us or our licensors and is protected by copyright and other intellectual property laws. You may not copy, distribute, modify, or create derivative works of any content on our website without our prior written consent.

Disclaimer of Warranties
Our website is provided "as is" and without warranties of any kind, either express or implied. We do not warrant that our website will be uninterrupted or error-free, that defects will be corrected, or that our website or the servers that make it available are free of viruses or other harmful components.

Limitation of Liability
In no event shall we be liable for any direct, indirect, incidental, consequential, special, or exemplary damages arising from or in connection with your use of our website, even if we have been advised of the possibility of such damages. Our liability to you for any cause whatsoever, and regardless of the form of the action, will at all times be limited to the amount paid by you, if any, to access our website.

Indemnification
You agree to indemnify, defend, and hold us harmless from any claim, demand, or damage, including reasonable attorneys' fees, arising out of your use of our website, your violation of these TOS, or your violation of any rights of another.

Governing Law and Jurisdiction
These TOS and any disputes arising out of or related to your use of our website will be governed by and construed in accordance with the laws of [insert jurisdiction], without giving effect to any principles of conflicts of law. Any legal action or proceeding arising out of or related to these TOS or your use of our website shall be brought exclusively in [insert court of jurisdiction], and you consent to the jurisdiction of such courts.

Modifications to these TOS
We reserve the right to modify these TOS at any time without notice. Your continued use of our website following any such modification constitutes your agreement to be bound by the modified TOS.
`;

package.jsonにseedコマンドを追加します。

"prisma": {
    "seed": "ts-node --compiler-options {\"module\":\"CommonJS\"} prisma/seed.ts"
},

実行します。

$ npx prisma db seed

データが作成されていることがPrisma Studioで確認できます。

globals/db.ts の作成

Next.jsの開発環境では、Prismaを使用するときに警告が発生する場合があります。この警告を回避するように実装します。

Best practice for instantiating PrismaClient with Next.js

まず、以下のコマンドを実行します。server-onlyは、クライアントサイドで実行されるコードとしてビルドされることを防ぐライブラリです。

$ npm install server-only

サーバーサイドでのみ実行されてほしいファイルにあらかじめserver-onlyをインポートしておくことで、Client Componentsから使用された際に、ビルド時エラーとして検知できます。これを生かして、次のようなglobals/db.tsを作成します。

import { PrismaClient } from '@prisma/client'
// Prismaはサーバーサイドでしか使用できないためserver-onlyをインポートする
import "server-only"

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const prisma =
  globalForPrisma.prisma ||
  new PrismaClient({
    log: ['query'],
  })

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

Prismaのインスタンスをこのglobals/db.ts経由で取得することで、警告を回避できます。

ここまでで、アプリを作成する下準備は完了です。次からはアプリの実装に入ります。

トップページの実装

アプリのトップページを実装します。今回はFlowriftを参考に、次のようなUIデザインを作成します。

next2

この章の内容を反映したブランチはtop-pageになります。

共通ヘッダーの実装

アプリケーションのグローバルヘッダーのコンポーネントを作ります。

その前に、zodをインストールします。

$ npm install zod

次の内容でapp/Header.tsxを作成します。

import Link from "next/link";
import { Suspense } from "react";
import "server-only";
import { prisma } from "../globals/db";
import { zVersion } from "./type";

const Header: React.FC = () => {
  const title = 'Awesome Note App'
  return (
    <div className="bg-white lg:pb-6">
      <div className="max-w-screen-2xl px-2 md:px-4 mx-auto">
        <header className="flex justify-between items-center py-4">
          {/* 1. トップページへのリンク */}
          <Link href="/" className="inline-flex items-center text-black-800 text-xl font-bold gap-2.5" aria-label="logo">
            <svg height="24" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z" /><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z" /></svg>
            {title}
          </Link>
          {/* 2. 画面幅が768px未満の場合は非表示 */}
          <nav className="hidden md:flex gap-12">
            {/* 3. リンク先は未実装のためトップページに遷移 */}
            <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Memo</Link>
            <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">FAQ</Link>
            <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Setting</Link>
          </nav>

          <div>
            <span className="inline-block focus-visible:ring ring-pink-300 text-gray-500 hover:text-pink-500 active:text-pink-600 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-2 py-3">
              <Suspense fallback={"loading..."}>
                {/* 4. 非同期のサーバーコンポーネント */}
                {/* @ts-expect-error Server Component */}
                <Version />
              </Suspense>
            </span>
          </div>
        </header>
      </div >
    </div >
  )
};

const Version = async () => {
  // 5. DBからデータ取得
  // versionをDBから取得
  const metadata = await prisma.metadata.findUniqueOrThrow({
    where: {
      key: "version"
    }
  });
  const version = zVersion.parse(metadata.value);
  return `v${version}`;
};

export default Header;

次の内容でapp/type.tsも作成します。

import { z } from 'zod';
//  6. APIやDBから取得した値の形式を定義
export const zVersion = z.string().regex(/^\d+\.\d+\.\d+$/);
export const zSettings = z.object({
  version: zVersion,
  faq: z.string(),
  tos: z.string(),
});
export type Settings = z.infer<typeof zSettings>;

次の内容をapp/layout.tsxに追加します。

import Header from './Header';
{/* 中略 */}
<body className={NotoSansJP.className}>
    {/* 7. 共通ヘッダー */}
    <Header></Header>
    {children}
</body>

この3つのファイルで説明したい箇所は次の7つです。

  1. SVGアイコンとタイトル文字列で、トップページへのリンクを実装しています。
  2. 画面幅が768px以上の場合は、md:flexが適用されて表示されます。
    768px未満の場合はhiddenが適用されて非表示になります。 今回のアプリでは表示崩れを防ぐため、画面幅が小さい場合はリンクを非表示にしています。
  3. 各ページへのリンクを置いていますが、まだ未実装のページのためトップページにリンクしています。
  4. 非同期のサーバーコンポーネントを使用しています。
    TypeScriptの型エラーが発生するため、一時的な回避策を使用しています。
  5. コンポーネント内でデータベースからデータを取得しています。
  6. APIを介して取得したデータは通常any型となるため、型チェック用のzodスキーマを定義しています。
  7. 共通のヘッダーをlayout.tsxに定義し、全てのページで表示します。

これでlocalhost:3000にアクセスすると、ヘッダーが表示されます。

ページの実装

トップページのコンテンツ部分を実装します。app/page.tsxを次の内容に修正します。

import Image from 'next/image';
import Link from 'next/link';
import coverPic from '../public/cover.jpeg';

export default function Page() {
  return (
    <main>
      <div className="bg-white pb-6 sm:pb-8 lg:pb-12">
        <div className="max-w-screen-2xl px-4 md:px-8 mx-auto">

          <section className="flex flex-col lg:flex-row justify-between gap-6 sm:gap-10 md:gap-16">
            <div className="xl:w-5/12 flex flex-col justify-center sm:text-center lg:text-left lg:py-12 xl:py-24">
              <p className="text-pink-500 md:text-lg xl:text-xl font-semibold mb-4 md:mb-6">Introducing the App Directory</p>

              <h1 className="text-black-800 text-4xl sm:text-5xl md:text-6xl font-bold mb-8 md:mb-12">Revolutionary way to build the web</h1>

              <p className="lg:w-4/5 text-gray-500 xl:text-lg leading-relaxed mb-4 md:mb-6">Learn about the new features of Next.js 13 through building a note application.</p>
              <p className="lg:w-4/5 text-gray-500 xl:text-lg leading-relaxed mb-8 md:mb-12">Front-end development will be more fun.</p>
              { /* ノート一覧・作成ページは未実装のためトップページに遷移 */ }
              <div className="flex flex-col sm:flex-row sm:justify-center lg:justify-start gap-2.5">
                <Link href="/" className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">Add new</Link>
                <Link href="/" className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-gray-500 active:text-gray-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">View list</Link>
              </div>
            </div>

            <div className="xl:w-5/12 h-48 lg:h-auto bg-gray-100 overflow-hidden shadow-lg rounded-lg">
              <Image src={coverPic} priority alt="Photo by Fakurian Design" className="w-full h-full object-cover object-center" />
            </div>

          </section>
        </div>
      </div>
    </main>
  )
}

public/cover.jpegには好きな画像を使用してください。今回はUnsplashMilad Fakurianが撮影した写真を使用しました。

これでlocalhost:3000にアクセスすると、トップページが表示されます。今のところはリンク先が未実装のため、全てトップページへ遷移するようにしています。

引き続き他のページの実装を行いましょう。

一覧ページの作成

今回のノートアプリでは一覧や詳細・編集など複数の画面を実装します。まずは一覧ページを作成します。

next3

この章の内容を反映したブランチはlist-pageになります。

一覧取得APIの作成実装

まずは既存のノート一覧を取得するAPIを実装しましょう。Route Handlersを使います。

次の内容でapp/api/notes/route.tsを作成します。

import { prisma } from "@/globals/db";
import { NextResponse } from "next/server";

// 1. 動的レンダリングを強制する
export const dynamic = 'force-dynamic';

// 2. ノート一覧を取得するAPI
export async function GET() {
  // 3. DBからノート一覧を取得
  const notes = await prisma.note.findMany();
  return NextResponse.json(notes)
}

ここで説明したい箇所は次の3つです。

  1. このAPIを使用するpagesやlayoutsは動的にレンダリングされるようになります。
    詳しくはドキュメントを参照してください。
  2. /api/notesGETリクエストが送られた場合の処理を記述します。
  3. notesテーブルから全てのデータを取得して返却します。

/api/notesにGETリクエストを送ると、ノート一覧を取得できるようになります。

次にAPIから取得したデータを表示する画面を作成します。app/notes/type.tsを作成し、APIから取得するデータの型を定義します。

import { z } from 'zod';

export const zNote = z.object({
  id: z.number().int(),
  title: z.string(),
  body: z.string(),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});
export const zNotes = z.array(zNote);
export const zUpsertNote = z.object({
  title: z.string(),
  body: z.string(),
});

export type Note = z.infer<typeof zNote>;
export type Notes = z.infer<typeof zNotes>;

constants/api.tsを作成して、サーバーサイドからアクセスする際のAPIのURLを定義します。

import "server-only";
export const apiUrl = "http://127.0.0.1:3000/api";

一覧表示コンポーネントの実装

続いてノート一覧を表示するコンポーネントapp/notes/NoteList.tsxを作成します。SWRを使用するため、あらかじめ以下のコマンドでインストールしておきましょう。

$ npm install swr

NoteList.tsxは以下の内容になります。

// 1. フックを用いているためClient Componentsとして定義
'use client'
import Link from "next/link";
import useSWR from "swr";
import { Note, zNotes } from "./type";

type Props = {
  initialState: Note[];
}

const fetcher = (url: string) => fetch(url).then(async (res) => {
  const data = await res.json();
  const notes = zNotes.parse(data);
  return notes;
});

const NoteList: React.FC<Props> = ({ initialState }) => {
  // 2. クライアントサイドでのデータ取得
  const { data } = useSWR('/api/notes', fetcher, { suspense: true, fallbackData: initialState })
  return (
    <div className="grid sm:grid-cols-2 xl:grid-cols-3 gap-8 sm:gap-y-10">
      {data.map(note => <NoteItem key={note.id} item={note} />)}
    </div>
  )
}

type NoteProps = {
  item: Note;
}

const NoteItem: React.FC<NoteProps> = ({ item }) => {
  return (
    <div className="bg-gray-100 rounded-lg relative p-5 pt-8">
      { /* ノート編集ページは未実装のため一覧ページに遷移 */ }
      <Link href={`/notes`} className="absolute -top-4 left-4">
        <span className="w-8 h-8 inline-flex justify-center items-center bg-pink-500 hover:bg-pink-700 text-white rounded-full">
          <svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 25 25" fill="currentColor">
            <path d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z" />
          </svg>
        </span>
      </Link>
      { /* ノート詳細ページは未実装のため一覧ページに遷移 */ }
      <Link href={`/notes`} prefetch={false}>
        <h3 className="text-pink-500 hover:text-pink-700 text-lg md:text-xl font-semibold mb-3 break-all underline underline-offset-2">{item.title}</h3>
      </Link>
      <p className="text-gray-500 break-all">{item.body}</p>
    </div>
  );
};

export default NoteList;

ここで説明したい箇所は次の2つです。

  1. このコンポーネントはフックを用いておりクライアントサイドでのみ使用するため、'use client'を記述します。
  2. SWRにてクライアントサイドでデータを取得します。
    定期的に最新のデータが取得されます。

一覧表示コンポーネントの実装

最後に、ノート一覧を表示するページapp/notes/page.tsxと、使用するコンポーネントを実装します。

import ErrorBoundary from '@/components/ErrorBoundary';
import FetchError from '@/components/FetchError';
import Loading from '@/components/Loading';
import { apiUrl } from "@/constants/api";
import Link from 'next/link';
import { Suspense } from 'react';
import "server-only";
import NoteList from './NoteList';
import { zNotes } from "./type";

// 1. 静的/動的レンダリングや再生成の間隔を指定
export const revalidate = 0;

export const metadata = {
  title: "List Notes",
}

export default async function Page() {
  // 2. APIを用いたデータ取得
  const notes = await getNotes();
  return (
    <main className="mx-2 sm:mx-4 relative">
      { /* ノート作成ページは未実装のため一覧ページに遷移 */ }
      <Link href="/notes" className="absolute top-0 right-2 z-10 text-white bg-pink-500 hover:bg-pink-700 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center mr-2">
        <svg aria-hidden="true" className="w-6 h-6" fill="currentColor" viewBox="4 4 8 8" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" clipRule="evenodd"></path></svg>
        <span className="sr-only">New Note</span>
      </Link>
      <h2 className='mb-6 text-gray-400 text-xs'>List Notes</h2>
      { /* 3. Client ComponentsのSuspenseの使用 */ }
      <ErrorBoundary fallback={<FetchError />}>
        <Suspense fallback={<Loading />}>
          <NoteList initialState={notes} />
        </Suspense>
      </ErrorBoundary>
    </main>
  )
}

export const getNotes = async () => {
  const res = await fetch(`${apiUrl}/notes`, { cache: 'no-store' });
  const data = await res.json();
  const notes = zNotes.parse(data);
  return notes;
};

このpage.tsxで説明したい箇所は次の3つです。

  1. このページのレンダリング方法を、Route Segment Config Optionsを使って指定します。
    0の場合 ... 静的な生成が行われず、常に動的にページを生成する
    0以上の数値の場合 ... その秒数経過後にリクエストがあった際にページを再生成する
    falseの場合 ... ビルド時に静的に生成した後、ページを再生成しない
    fetch APIや他ページ、レイアウトでの指定にも影響されるため、上記の挙動にならない場合もあります。 詳しい設定値はドキュメントを参照してください。 また、本稿のAppendixの「revalidationの更新時間を見てみる」では、revalidateの値による挙動の違いについて触れています。
  2. ページ内でAPIを用いてサーバーサイドにてノート一覧を取得します。
  3. Client Componentsを、Suspenseを用いて使用しています。
    Client Components内部でPromiseやErrorがthrowされた場合は、それぞれのfallbackが表示されます。

以下はpage.tsx内で使用しているコンポーネントです。まずcomponents/ErrorBoundary.tsxです。

'use client'
import React, { ReactNode } from "react";

type Props = { fallback: ReactNode, children: ReactNode };

class ErrorBoundary extends React.Component<Props, { hasError: boolean }> {
  constructor(props: Props) {
    super(props);
    this.state = {
      hasError: false,
    };
  }
  static getDerivedStateFromError(): { hasError: boolean } {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) {
      return <>{this.props.fallback}</>;
    }
    return <>{this.props.children}</>;
  }
}

export default ErrorBoundary;

次がcomponents/FetchError.tsxです。

const FetchError: React.FC = () => {
  return (
    <div className="flex justify-center">
      <div className="bg-pink-100 border text-pink-700 px-4 py-3 rounded" role="alert">
        <h3 className="font-bold">Error while fetching data.</h3>
        <span className="block sm:inline">Something seriously bad happened.</span>
      </div>
    </div>
  )
}
export default FetchError;

最後にcomponents/Loading.tsxです。

const Loading: React.FC = () => {
  return (
    <div className="flex justify-center">
      <div className="animate-ping h-2 w-2 bg-pink-600 rounded-full"></div>
      <div className="animate-ping h-2 w-2 bg-pink-600 rounded-full mx-4"></div>
      <div className="animate-ping h-2 w-2 bg-pink-600 rounded-full"></div>
    </div>
  )
}

export default Loading;

ここまで実装すると、ノート一覧ページが表示されるようになります。localhost:3000/notesにアクセスしてみてください。

一覧ページが実装できたので、一覧ページにリンクさせる部分を実装しておきましょう。

まずapp/Header.tsxの修正差分です。

- <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Memo</Link>
+ <Link href="/notes" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Memo</Link>

次にapp/page.tsxの修正差分です。

- <Link href="/" className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-gray-500 active:text-gray-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">View list</Link>
+ <Link href="/notes" className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-gray-500 active:text-gray-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">View list</Link>

ノートの作成ページの実装

次にノートを新規に作成できるページを実装します。

next4

この章の内容を反映したブランチはnew-pageになります。

APIの実装

api/notes/route.tsの末尾に以下を追加し、POSTリクエストを受け付けるようにします。

export async function POST(req: NextRequest) {
  const data = await req.json();
  const parcedData = zUpsertNote.parse(data);
  const note = await prisma.note.create({
    data: { title: parcedData.title, body: parcedData.body },
  });
  return new NextResponse(`${note.id}`, { status: 201 })
}

/api/notesへのPOSTリクエストでノートを作成できるようになりました。

コンポーネントの実装

フォームに入力して内容を送信するためのコンポーネントapp/notes/new/NewNote.tsxを作成します。

'use client'
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { z } from "zod";

const NewNote: React.FC = () => {
  const router = useRouter();
  // 1. フォームの入力値を管理するためのstate
  const [title, setTitle] = useState("");
  const [body, setBody] = useState("");
  // 2. 作成APIを呼び出す関数
  const createNote = useCallback(async () => {
    const res = await fetch(`/api/notes`, {
      method: 'POST',
      body: JSON.stringify({ title, body }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    if (res.ok) {
      const id = z.number().parse(await res.json());
      alert('Note created');
      // 詳細ページが実装されたら、詳細ページに遷移するようにする
      router.push(`/notes`);
      // 3. 現在のページのデータをサーバーから再取得する
      router.refresh();
    } else {
      alert('Note failed to create');
    }
  }, [body, router, title]);

  return (
    <div className="flex flex-col bg-gray-100 rounded-lg relative p-5 gap-2.5">
      <div className="sm:col-span-2">
        <label htmlFor="title" className="inline-block text-gray-800 text-sm sm:text-base mb-2">Title</label>
        <input
          name="title"
          className="w-full bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>

      <div className="sm:col-span-2">
        <label htmlFor="body" className="inline-block text-gray-800 text-sm sm:text-base mb-2">Body</label>
        <textarea
          name="body"
          className="w-full h-64 bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={body}
          onChange={(e) => setBody(e.target.value)}
        ></textarea>
      </div>

      <div className="flex flex-col sm:flex-row sm:justify-end gap-2.5">
        <Link href={`/notes`} className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-gray-500 active:text-gray-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Cancel</Link>
        <button onClick={createNote} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Create</button>
      </div>
    </div>
  );
}

export default NewNote;

ここで説明したい箇所は次の3つです。

  1. フォームの入力値を管理するためにuseStateを使用しています。
  2. 作成APIを呼び出す関数を定義しています。
    内部でstateに保持していた値を先ほど実装したAPIに送信しています。
  3. 作成に成功した際、現在のページのデータを再取得するためにrouter.refresh()を呼び出します。

ページの実装

コンポーネントを使用するページapp/notes/new/page.tsxを実装します。

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

export const metadata = {
  title: "New Note",
};

export default async function Page() {
  return (
    <main className="mx-2 sm:mx-4">
      <Link href={`/notes`} className='inline-block focus-visible:ring ring-pink-300 text-gray-500 hover:text-pink-500 active:text-pink-600 text-s md:text-base font-semibold rounded-lg outline-none transition duration-100'>← back</Link>
      <h2 className='my-4 text-gray-400 text-xs'>New Note</h2>
      <NewNote></NewNote>
    </main>
  )
}

/notes/newへアクセスすると、ノート作成画面が表示されます。内容を入力して「Create」ボタンを押すと、ノートが追加されます。一覧画面で確認してみましょう。

ノート作成ページが実装できたので、ノート作成ページにリンクさせる部分を実装しておきましょう。

まずapp/page.tsxの修正差分です。

- <Link href="/" className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">Add new</Link>
+ <Link href="/notes/new" className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-3">Add new</Link>

次にapp/notes/page.tsxの修正差分です。

- <Link href="/notes" className="absolute top-0 right-2 z-10 text-white bg-pink-500 hover:bg-pink-700 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center mr-2">
+ <Link href="/notes/new" className="absolute top-0 right-2 z-10 text-white bg-pink-500 hover:bg-pink-700 font-medium rounded-full text-sm p-2.5 text-center inline-flex items-center mr-2">
  <svg aria-hidden="true" className="w-6 h-6" fill="currentColor" viewBox="4 4 8 8" xmlns="http://www.w3.org/2000/svg"><path fillRule="evenodd" d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z" clipRule="evenodd"></path></svg>
  <span className="sr-only">New Note</span>
</Link>

ノートの詳細・編集・削除ページの作成

次に、ノートの詳細・編集・削除ページを続けて作成します。基本的な流れは一覧や追加画面と変わらないため具体的な解説は省略し、コードだけを掲載します。

next5

この章の内容を反映したブランチはcrud-pageになります。

APIの実装

詳細取得・更新・削除のAPIを続けてapp/api/notes/[id]/route.tsで実装します。

import { zUpsertNote } from "@/app/notes/type";
import { prisma } from "@/globals/db";
import { NextRequest, NextResponse } from "next/server";

// /api/notes/[id]/route.ts
// ノートのIDはパスパラメーター`[id]`で受け取る

// ノートを1件取得
export async function GET(_req: NextRequest, { params }: { params: { id: string } }) {
  const note = await prisma.note.findUnique({
    where: { id: Number(params.id) },
  });
  if (note === null) {
    return new NextResponse(null, { status: 404 })
  }
  return NextResponse.json(note)
}

// ノートを更新
export async function PUT(req: NextRequest, { params }: { params: { id: string } }) {
  const data = await req.json();
  const parcedData = zUpsertNote.parse(data);
  const note = await prisma.note.update({
    where: { id: Number(params.id) },
    data: { title: parcedData.title, body: parcedData.body },
  });
  return new NextResponse(null, { status: 204 })
}

// ノートを削除
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
  const note = await prisma.note.delete({
    where: { id: Number(params.id) },
  });
  return new NextResponse(null, { status: 204 })
}

詳細ページ(コンポーネント・ページの実装)

まずは詳細ページと削除機能の実装です。app/notes/[id]/getNote.tsです。

import { apiUrl } from "@/constants/api";
import "server-only";
import { zNote } from "../type";

export const getNote = async (id: string) => {
  const res = await fetch(`${apiUrl}/notes/${id}`, { cache: 'no-store' });
  const data = await res.json();
  const note = zNote.parse(data);
  return note;
};

次にtsx:app/notes/[id]/Note.tsxです。

'use client';
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { Note } from "../type";

type Props = {
  item: Note;
}

const Note: React.FC<Props> = ({ item }) => {
  const router = useRouter();
  const deleteNote = useCallback(async () => {
    const res = await fetch(`/api/notes/${item.id}`, {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json'
      }
    });
    if (res.ok) {
      alert('Note deleted');
      router.push(`/notes`);
      router.refresh();
    } else {
      alert('Note failed to delete');
    }
  }, [item.id, router]);

  return (
    <div className="flex flex-col bg-gray-100 rounded-lg relative p-5 gap-2.5">
      <h3 className="text-pink-500 text-lg md:text-xl font-semibold break-all">{item.title}</h3>
      <p className="text-gray-500 break-all">{item.body}</p>

      <div className="flex flex-col sm:flex-row sm:justify-end gap-2.5">
        <Link href={`/notes/${item.id}`} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Edit</Link>
        <button onClick={deleteNote} className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-red-500 active:text-red-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Delete</button>
      </div>
    </div>
  );
}

export default Note;

そしてapp/notes/[id]/page.tsxです。

import Link from 'next/link';
import { Metadata } from 'next/types';
import { getNote } from './getNote';
import Note from './Note';

export const revalidate = 0;

// ページのメタデータを動的に取得
export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const note = await getNote(params.id);
  return { title: note.title }
}

export default async function Page({ params }: { params: { id: string } }) {
  const note = await getNote(params.id);
  return (
    <main className="mx-2 sm:mx-4">
      <Link href="/notes" className='inline-block focus-visible:ring ring-pink-300 text-gray-500 hover:text-pink-500 active:text-pink-600 text-s md:text-base font-semibold rounded-lg outline-none transition duration-100'>← back</Link>
      <h2 className='my-4 text-gray-400 text-xs'>View Note</h2>
      <Note item={note} />
    </main>
  )
}

/notes/[id]にアクセスすると、詳細ページが表示されるようになります。「Delete」ボタンを押すと、該当のノートが削除されます。

詳細ページにリンクさせる部分を実装しておきましょう。app/notes/NoteList.tsxの修正差分です。

- <Link href={`/notes`} prefetch={false}>
+ <Link href={`/notes/${item.id}`} prefetch={false}>
  <h3 className="text-pink-500 hover:text-pink-700 text-lg md:text-xl font-semibold mb-3 break-all underline underline-offset-2">{item.title}</h3>
</Link>

編集ページ(コンポーネント・ページの実装)

次に編集ページを実装します。app/notes/[id]/edit/EditNote.tsxです。

'use client'
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { Note } from "../../type";

type Props = {
  item: Note;
}

const EditNote: React.FC<Props> = ({ item }) => {
  const router = useRouter();
  const [title, setTitle] = useState(item.title);
  const [body, setBody] = useState(item.body);
  const updateNote = useCallback(async () => {
    const res = await fetch(`/api/notes/${item.id}`, {
      method: 'PUT',
      body: JSON.stringify({ title, body }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    if (res.ok) {
      alert('Note updated');
      router.push(`/notes/${item.id}`);
      router.refresh();
    } else {
      alert('Note failed to update');
    }
  }, [body, item.id, router, title]);

  return (
    <div className="flex flex-col bg-gray-100 rounded-lg relative p-5 gap-2.5">
      <div className="sm:col-span-2">
        <label htmlFor="title" className="inline-block text-gray-800 text-sm sm:text-base mb-2">Title</label>
        <input
          name="title"
          className="w-full bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
        />
      </div>

      <div className="sm:col-span-2">
        <label htmlFor="body" className="inline-block text-gray-800 text-sm sm:text-base mb-2">Body</label>
        <textarea
          name="body"
          className="w-full h-64 bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={body}
          onChange={(e) => setBody(e.target.value)}
        ></textarea>
      </div>

      <div className="flex flex-col sm:flex-row sm:justify-end gap-2.5">
        <Link href={`/notes/${item.id}`} className="inline-block bg-gray-200 hover:bg-gray-300 focus-visible:ring ring-pink-300 text-gray-500 active:text-gray-700 text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Cancel</Link>
        <button onClick={updateNote} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Save</button>
      </div>
    </div>
  );
}

export default EditNote;

次にapp/notes/[id]/edit/page.tsxです。

import Link from 'next/link';
import { Metadata } from 'next/types';
import { getNote } from '../getNote';
import EditNote from './EditNote';
export const revalidate = 0;

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const note = await getNote(params.id);
  return { title: note.title }
}

export default async function Page({ params }: { params: { id: string } }) {
  const note = await getNote(params.id);
  return (
    <main className="mx-2 sm:mx-4">
      <Link href={`/notes/${params.id}`} className='inline-block focus-visible:ring ring-pink-300 text-gray-500 hover:text-pink-500 active:text-pink-600 text-s md:text-base font-semibold rounded-lg outline-none transition duration-100'>← back</Link>
      <h2 className='my-4 text-gray-400 text-xs'>Edit Note</h2>
      <EditNote item={note} />
    </main>
  )
}

/notes/[id]/editにアクセスすると、編集ページが表示されるようになります。「Save」ボタンを押すと、該当のノートが更新されます。

編集ページにリンクさせる部分を実装しておきましょう。app/notes/NoteList.tsxの修正差分です。

- <Link href={`/notes`} className="absolute -top-4 left-4">
+ <Link href={`/notes/${item.id}/edit`} className="absolute -top-4 left-4">
  <span className="w-8 h-8 inline-flex justify-center items-center bg-pink-500 hover:bg-pink-700 text-white rounded-full">
    <svg xmlns="http://www.w3.org/2000/svg" className="w-5 h-5" viewBox="0 0 25 25" fill="currentColor">
      <path d="M7.127 22.562l-7.127 1.438 1.438-7.128 5.689 5.69zm1.414-1.414l11.228-11.225-5.69-5.692-11.227 11.227 5.689 5.69zm9.768-21.148l-2.816 2.817 5.691 5.691 2.816-2.819-5.691-5.689z" />
    </svg>
  </span>
</Link>

次はapp/notes/[id]/Note.tsxの修正差分です。

- <Link href={`/notes/${item.id}`} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Edit</Link>
+ <Link href={`/notes/${item.id}/edit`} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Edit</Link>

設定ページの実装

ノートアプリとしてのひととおりの機能は作成完了しました。最後に、設定ページとヘルプページを作成しながら残りの機能の解説を行っていきます。設定ページでは、バージョン・FAQ・利用規約の内容の確認と更新ができるようにします。

next6

この節の内容を反映したブランチはsettings-pageになります。

APIの実装

まずは設定を上書きするAPIをapp/api/settings/route.tsに実装します。

import { zSettings } from "@/app/type";
import { prisma } from "@/globals/db";
import { NextRequest, NextResponse } from "next/server";

export async function PUT(req: NextRequest) {
  const data = await req.json();
  const parcedData = zSettings.parse(data);
  // トランザクションを使って、複数のデータを一度に更新する
  await prisma.$transaction([
    prisma.metadata.update({
      where: { key: "version" },
      data: { value: parcedData.version },
    }),
    prisma.metadata.update({
      where: { key: "faq" },
      data: { value: parcedData.faq },
    }),
    prisma.metadata.update({
      where: { key: "tos" },
      data: { value: parcedData.tos },
    }),
  ]);
  return new NextResponse(null, { status: 204 })
}

コンポーネント・ページの実装

以下の内容でapp/settings/EditSettings.tsxを作成します。

'use client'
import { useRouter } from "next/navigation";
import { useCallback, useState } from "react";
import { Settings } from "../type";
type Props = {
  value: Settings;
}
const EditSettings: React.FC<Props> = ({ value }) => {
  const router = useRouter();
  const [version, setVersion] = useState(value.version);
  const [faq, setFaq] = useState(value.faq);
  const [tos, setTos] = useState(value.tos);
  const updateSettings = useCallback(async () => {
    const res = await fetch(`/api/settings`, {
      method: 'PUT',
      body: JSON.stringify({ version: version, faq: faq, tos: tos }),
      headers: {
        'Content-Type': 'application/json'
      }
    });
    if (res.ok) {
      alert('Settings updated');
      router.refresh();
    } else {
      alert('Settings failed to update');
    }
  }, [faq, router, tos, version]);

  return (
    <div className="flex flex-col bg-gray-100 rounded-lg relative p-5 gap-2.5">
      <div className="sm:col-span-2">
        <label htmlFor="version" className="inline-block text-gray-800 text-sm sm:text-base mb-2">Version</label>
        <input
          name="version"
          className="w-full bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={version}
          onChange={(e) => setVersion(e.target.value)}
        />
      </div>

      <div className="sm:col-span-2">
        <label htmlFor="faq" className="inline-block text-gray-800 text-sm sm:text-base mb-2">FAQ</label>
        <textarea
          name="faq"
          className="w-full h-64 bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={faq}
          onChange={(e) => setFaq(e.target.value)}
        ></textarea>
      </div>

      <div className="sm:col-span-2">
        <label htmlFor="tos" className="inline-block text-gray-800 text-sm sm:text-base mb-2">TOS</label>
        <textarea
          name="tos"
          className="w-full h-64 bg-gray-50 text-gray-800 border focus:ring ring-pink-300 rounded outline-none transition duration-100 px-3 py-2"
          value={tos}
          onChange={(e) => setTos(e.target.value)}
        ></textarea>
      </div>

      <div className="flex flex-col sm:flex-row sm:justify-end gap-2.5">
        <button onClick={updateSettings} className="inline-block bg-pink-500 hover:bg-pink-600 active:bg-pink-700 focus-visible:ring ring-pink-300 text-white text-sm md:text-base font-semibold text-center rounded-lg outline-none transition duration-100 px-8 py-2">Save</button>
      </div>
    </div>
  );
}

export default EditSettings;

次はapp/settings/page.tsxです。

import { prisma } from "@/globals/db";
import "server-only";
import { zSettings } from "../type";
import EditSettings from "./EditSettings";

export const revalidate = 0;

export const metadata = {
  title: 'Settings',
}

export default async function Page() {
  // ページ内でのDBからのデータ取得
  const settings = await getSettings();
  return (
    <main className="mx-2 sm:mx-4">
      <h2 className='my-4 text-gray-400 text-xs'>Settings</h2>
      <EditSettings value={settings} />
    </main>
  )
}

const getSettings = async () => {
  const settings = await prisma.metadata.findMany();
  const data = settings.reduce<Record<string, string>>((acc, cur) => {
    acc[cur.key] = cur.value;
    return acc;
  }, {});
  const parsedData = zSettings.parse(data);
  return parsedData;
}

/settingsにアクセスすると、設定画面が表示されます。npm run devで動かしている場合、バージョンを更新するとヘッダーに表示されている値が変更されることを確認できます。

設定ページにリンクさせる部分を実装しておきましょう。app/Header.tsxの修正差分です。

- <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Setting</Link>
+ <Link href="/settings" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">Setting</Link>

FAQページと利用規約ページの実装

設定ページで更新したデータを表示するページを実装します。

next7

next8

この章の内容を反映したブランチはhelp-pageになります。

共通レイアウト

まずはFAQと利用規約に共通するレイアウトとして、app/help/layout.tsxを作成します。

'use client'
import Link from "next/link";
import { usePathname } from "next/navigation";

// `/help/faq`と`/help/tos`で共通するレイアウト
// https://beta.nextjs.org/docs/routing/pages-and-layouts#nesting-layouts
export default function Layout({
  children,
}: {
  children: React.ReactNode,
}) {
  // パスを取得してUIを変更する
  const pathname = usePathname();
  return (
    <section className="mx-2 sm:mx-4">
      {/* Include shared UI here e.g. a header or sidebar */}
      <nav className="flex gap-12 mb-4">
        <Link href="/help/faq" className={`${pathname === '/help/faq' ? 'text-pink-500 font-semibold' : 'text-gray-600 font-normal'} hover:text-pink-500 active:text-pink-700 text-lg transition duration-100`}>FAQ</Link>
        <Link href="/help/tos" className={`${pathname === '/help/tos' ? 'text-pink-500 font-semibold' : 'text-gray-600 font-normal'} hover:text-pink-500 active:text-pink-700 text-lg transition duration-100`}>Terms</Link>
      </nav>
      {children}
    </section>
  );
}

FAQページと利用規約ページ

続けてapp/help/faq/page.tsxを作成します。

import Nl2br from "@/components/Nl2br";
import { prisma } from "@/globals/db";

// 30秒ごとに再生成
export const revalidate = 30;

export default async function Page() {
  const data = await prisma.metadata.findUniqueOrThrow({
    where: { key: "faq" },
  });
  return (
    <main>
      <h1 className="text-xl my-2">Frequently Asked Questions</h1>
      <p className="text-xs text-gray-400 my-2">The following text is a sample.</p>
      <Nl2br>{data.value}</Nl2br>
    </main>
  );
}

同じくapp/help/tos/page.tsxを作成します。

import Nl2br from "@/components/Nl2br";
import { prisma } from "@/globals/db";

// 常に再生成
export const revalidate = 0;

export default async function Page() {
  const data = await prisma.metadata.findUniqueOrThrow({
    where: { key: "tos" },
  });
  return (
    <main>
      <h1 className="text-xl my-2">Terms of Service</h1>
      <p className="text-xs text-gray-400 my-2">The following text is a sample.</p>
      <Nl2br>{data.value}</Nl2br>
    </main>
  );
}

これらで使用しているコンポーネントcomponents/Nl2br.tsxです。

const Nl2br = ({ children }: { children: string }) => (
  <>
    {children.split(/(\n)/g).map((t, index) => (t === '\n' ? <br key={index} /> : t))}
  </>
)

export default Nl2br;

これで/help/faq/help/tosにアクセスすると、FAQページと利用規約ページが表示されます。両方のページに共通のヘッダーが表示されており、遷移できることが確認できます。

FAQページにリンクさせる部分を実装しておきましょう。app/Header.tsxの修正差分です。

- <Link href="/" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">FAQ</Link>
+ <Link href="/help/faq" className="text-gray-600 hover:text-pink-500 active:text-pink-700 text-lg font-semibold transition duration-100">FAQ</Link>

ノートアプリの実装は以上で完了となります。お疲れさまでした。

ここまで、Next.js 13で追加された機能を使ってアプリを構築する方法を学んできました。アプリを作る上での基礎となる機能を、ひと通り使えるようになったのではないかと思います。

レンダリングやフォールバックの細かな挙動

ここまでで触れていないNext.js 13の細かな挙動について解説します。次のページも参考になるでしょう。

Next.js 13 App Playground – Vercel

静的レンダリングと動的レンダリングの違い

静的レンダリングと動的レンダリングのそれぞれ、またその挙動の違いを見ていきましょう。本節の挙動確認は、開発時のnpm run devではなく、以下のコマンドで行います。

$ npm run build
$ npm run start

まず、ビルド時の出力を確認します。

$ npm run build

> nextjs-app-directory-demo@0.1.0 build
> next build

# 一部省略

[    ] info  - Generating static pages (0/6)prisma:query SELECT `main`.`metadata`.`id`, `main`.`metadata`.`key`, `main`.`metadata`.`value` FROM `main`.`metadata` WHERE (`main`.`metadata`.`key` = ? AND 1=1) LIMIT ? OFFSET ?
# 一部省略
prisma:query SELECT `main`.`metadata`.`id`, `main`.`metadata`.`key`, `main`.`metadata`.`value` FROM `main`.`metadata` WHERE (`main`.`metadata`.`key` = ? AND 1=1) LIMIT ? OFFSET ?
info  - Generating static pages (6/6)
info  - Finalizing page optimization

Route (app)                                Size     First Load JS
┌ ○ /                                      5.3 kB         78.9 kB
├ λ /api/notes                             0 B                0 B
├ λ /api/notes/[id]                        0 B                0 B
├ λ /api/settings                          0 B                0 B
├ ○ /help/faq                              137 B          68.3 kB
├ λ /help/tos                              137 B          68.3 kB
├ λ /notes                                 5.37 kB        91.2 kB
├ λ /notes/[id]                            1.28 kB        74.9 kB
├ λ /notes/[id]/edit                       1.44 kB          75 kB
├ ○ /notes/new                             947 B          86.8 kB
└ λ /settings                              1.38 kB        69.5 kB
+ First Load JS shared by all              68.1 kB
  ├ chunks/679-beff050763128f36.js         65.8 kB
  ├ chunks/main-app-160d840221a936ce.js    207 B
  └ chunks/webpack-8aae2c02305a58b2.js     2.14 kB

Route (pages)                              Size     First Load JS
─ ○ /404                                   179 B          91.3 kB
+ First Load JS shared by all              91.1 kB
  ├ chunks/main-0498766c9aa068a0.js        88.7 kB
  ├ chunks/pages/_app-5841ab2cb3aa228d.js  192 B
  └ chunks/webpack-8aae2c02305a58b2.js     2.14 kB

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

ビルドされたファイルがλ (Server)○ (Static)に分類されていること、ビルド時にデータベースにクエリが発行されていることが見て取れます。Serverに分類されているファイルは、毎アクセス時にレンダリングを行います。Staticでは、ビルド時にHTMLを生成しておくことで初回アクセス時のレンダリングを高速化しています。

デフォルトはStaticですが、動的関数動的データフェッチを使用した場合はServerになります。今回の例では//notes/newはrevalidateを設定していないため、/help/faqはrevalidateを30に設定しているためStaticになっています。それ以外のページはparamsを使用していたり、revalidateを0に設定していたり等の理由からServerになっています。

以下のコマンドを実行して、アプリを起動します。

$ npm run start

/settingsを開き、バージョンを更新してみましょう。以下のことが確認できます。

  • ページ右上のバージョンが更新されている
  • metadataテーブルのversionが更新されている(Prisma Studioで確認)
  • /を開いた際の右上のバージョンが、更新前の値となっている

/ではビルド時に生成されたHTMLを表示しており、その際に取得したバージョンが表示されるため、このような挙動となります。

revalidation(再検証)による更新時間

Staticの中にも、一定時間経過後にアクセスされた場合に更新されるページがあります。

これはrevalidation(再検証)と呼ばれます。/help/faqrevalidate = 30を設定しているため、30秒経過後に新たにアクセスされるとページが更新されるはずです。

/settingsを開き、FAQを変更して保存してみましょう。以下のことが確認できます。

  • /help/faqを開いてリロードすると、変更後のFAQが表示される
  • すぐに再度/settingsからFAQを更新し、すぐに/help/faqをリロードしてもFAQは再変更されない
  • 30秒程度待ってから/help/faqをリロードすると、再変更後のFAQが表示される

npm run startprisma:queryログからも、/help/faqを開いたタイミングでクエリが毎回発行されているわけではないことが確認できます。

一方、revalidate = 0を設定している/help/tosは、変更後すぐに反映されます。

Loadingフォールバック

今回のアプリケーションには登場していませんが、loading.tsxを使うことでページ全体をサスペンドさせることができます。

以下は、/help/tosからPromiseをthrowすることでLoadingフォールバックを表示させる例です。まず、次のようなapp/help/tos/loading.tsxを作成します。

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return "Loading...";
}

次をapp/help/tos/page.tsxに追加します。

export default async function Page() {
  throw new Promise(() => { });
  // ...
}

レイアウトと同時にLoading...と表示されることが確認できます。この例でPromiseが解決することはありませんが、実際は解決後にページが表示されます。

Errorフォールバック

loading.tsxと同じように、ページ全体をエラーページへフォールバックさせることも可能です。

以下は/help/tosからエラーをthrowすることでErrorフォールバックを表示させる例です。まず、次のようなapp/help/tos/error.tsxを作成します。

'use client';
export default function Error() {
  // You can add any UI inside Loading, including a Skeleton.
  return "Error!!!";
}

次をapp/help/tos/page.tsxに追加します。

export default async function Page() {
  throw new Error();
  // ...
}

レイアウトと同時にError!!!と表示されていることが確認できます。

error.tsxでは、errorresetという2つのpropsが使用できます。errorはthrowされたError変数、resetはエラーを引き起こしたページの再レンダリングを試みる関数です。

なおerror.tsxは、同階層にあるlayout.tsxのエラーを処理しないため、ルートのエラーを処理するためのglobal-error.tsxが存在します。

最後に ── Next.jsの先進的な機能を楽しんで

本稿では、Next.js 13で追加された新しい機能、主にApp Directoryを用いたWebアプリケーション開発について説明しました。現時点でも公式ドキュメントが充実しており、順を追って参照するだけでアプリケーションを作成できるような環境が整っています。

Next.jsは、数あるWebアプリケーションフレームワークの中でも、先進的な機能を積極的に取り入れているフレームワークのひとつです。今後のアップデートもNext.jsを使っている開発者の一人として楽しんでいけたらと思います。

本稿が、読者の皆さんが取り組む開発の一助になれば幸いです。

村田 司(MURATA Tsukasa)GitHub: Tim0401

next9
情報系の専門学校を経て、Webシステム開発会社に入社。受託開発をメインにリードのエンジニアとして勤務。アンドパッドには2021年にジョインし、現在はエンジニアとしてプロダクトの開発に携わる。好きな言語はTypeScriptとGoで、技術イベントにもLTなどで参加している(Speaker Deck)最近はVTuberにハマっており、暇があれば配信を見る日々を過ごしている。

編集:中薗 昴
制作:はてな編集部


  1. 「クラウド型施工管理サービスの市場動向とベンダーシェア」(デロイト トーマツ ミック経済研究所調べ)より
若手ハイキャリアのスカウト転職