Open49

Next.js の公式チュートリアルを読んでみる

よつよつ

Next.js では、公式が "Learn Next.js" というチュートリアル記事を公開している。

https://nextjs.org/learn/dashboard-app

フルスタックの Web アプリケーションを作成しながら、 App Router について学べるっぽい。
また、Vercel をつかったデプロイもサポートしているらしく、本番環境でのデータベースのセットアップなども学べる。

Next.js はデフォルトで React.jsをサポートしているが、もし React.jsの経験が浅い場合は、同じく Next.js が書いている "React Foundations" を一通り読んで学ぶことを公式は推奨している。

https://nextjs.org/learn/react-foundations

よつよつ

プロジェクトを作成する

Next.js アプリを作成する際は、create-next-appパッケージを使用することが推奨されている。
たとえばexampleプロジェクトを作成する場合は、以下の通りとなる。

npx create-next-app example

今回は Next.js 公式により用意された starter-example というテンプレートを使用するので、末尾に--exampleオプションを付与している。

npx create-next-app@latest nextjs-dashboard --use-npm --example "https://github.com/vercel/next-learn/tree/main/dashboard/starter-example"

作成したフォルダに移動して、作業を始める。

cd nextjs-dashboard
よつよつ

プロジェクトを探索する

プロジェクトは、以下のような構造になっている。

このうち、特筆すべきものを挙げておく。

  • /app: アプリケーションのルーティング、コンポーネント、ロジックが含まれる部分。アプリの核心となるフォルダ。
    • /app/lib: アプリケーションで使用される関数を含むフォルダ。
    • /app/ui: UIコンポーネントを含むフォルダ。
  • next.config.js: アプリの諸設定を含むファイル。

このうち、作業によって主に編集するのは/appフォルダ内部となることも覚えておいてほしい。

よつよつ

プレースホルダデータ

/app/lib/placeholder-data.jsでは、いくつかのサンプルデータが確認できる。

const invoices = [
  {
    customer_id: customers[0].id,
    amount: 15795,
    status: 'pending',
    date: '2022-12-06',
  },
  {
    customer_id: customers[1].id,
    amount: 20348,
    status: 'pending',
    date: '2022-11-14',
  },
  // ...
];

これはバックエンドを準備していない状態でUIを設定したり、後々データベースのセットアップをする際に使用するっぽい。

よつよつ

Next.js と TypeScript

Next.js では、プロジェクトで TypeScript を使用している場合、自動的に必要なパッケージをインストールしてくれる。
また、Next.js と Typescript を使った開発のためのプラグインも提供されている。

https://nextjs.org/docs/app/building-your-application/configuring/typescript#typescript-plugin

備考だが、TypeScript を使用する場合、データベースに Prisma を使用することを公式が推奨している。これは Prisma がスキーマに基づいて型を自動生成してくれるため、手動で宣言するほかの構成より型安全であるからだと思われる。

よつよつ

開発サーバーを立ち上げてみる

念のため、パッケージをインストールしておく。

npm i

そしたら開発サーバーを起動する。

 npm run dev

問題なく実行できれば、以下のような表示が出る。

表示に従って localhost:3000にアクセスすると、以下のようなページが開く。

よつよつ

スタイリング

現状、ホームページにはスタイルが適用されていない。そのため、ここからは CSS を使ってスタイリングを行っていく。

グローバルスタイル

/app/ui/global.cssには、すでにレイアウトが記述されている。

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

input[type='number'] {
  -moz-appearance: textfield;
  appearance: textfield;
}

input[type='number']::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type='number']::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

これを/app/layout.tsxでインポートすることで、全画面に対して共通のスタイルを適用する。
このファイルは ルートレイアウト と呼ばれ、すべてのページの大元となっている。

> import "@/app/ui/global.css";

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

この状態でもういちど開発サーバーを起動するか、先ほど開いたlocalhost:3000のページをリロードすると、このようにスタイルが適用されていることがわかる。

よつよつ

Next.js と Tailwind CSS

もう一度 global.cssの中身を確認してみる。一見すると、ホームページを構成するようなCSSは見当たらない。

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

input[type='number'] {
  -moz-appearance: textfield;
  appearance: textfield;
}

input[type='number']::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

input[type='number']::-webkit-outer-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

最初の三行で、見慣れないアノテーションを記述している。これはTailwind CSSのための記述である。
Tailwind CSS では、スタイルを付与したい対象に定められたクラスを指定することでスタイリングができる。例として、ホームページを構成する/app/page.tsxを見ると、要素にいくつかのクラスを付与していることが確認できる。

export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
    // ...
  )
}

Tailwind の記法については、公式ドキュメントを参照することをお勧めする。

よつよつ

CSS Modules

簡易なデザインであれば Tailwind CSS のようなライブラリを採用するのがよいが、より複雑なデザインに挑戦する場合、Vanilla CSS を使用したい場合がある。
そうした場合、単に CSS ファイルを乱立させるより、 CSS Modules を使用するべきである。

Next.js では、複数の CSS をファイルにまとめ、単一の要素に対して紐づけすることで、コンパクトなスタイリングを実現することができる。
例として、/app/ui/home.module.cssを作成し、以下のように記述する。

.shape {
  height: 0;
  width: 0;
  border-bottom: 30px solid black;
  border-left: 20px solid transparent;
  border-right: 20px solid transparent;
}

これは、指定したクラス内に黒い三角形を記述する CSS となる。

次に、/app/page.tsxでファイルをインポートする。

import styles from '@/app/ui/home.module.css';
...

最後に、CSSモジュールを適用したdivタグを追加する。

export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
        {/* <AcmeLogo /> */}
      </div>
      <div className="mt-4 flex grow flex-col gap-4 md:flex-row">
        <div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
>         <div className={styles.shape} />
          <p className={`text-xl text-gray-800 md:text-3xl md:leading-normal`}>
            ...
  );
}

そしたら、開発サーバーを起動するか、すでに開いているページをリロードする。すると、CSS モジュールで定義した図形が表示されていることがわかる。

よつよつ

clsx を使用したスタイリング

clsx(クラスエックス) は、より動的にクラスを適用したい場合に役立つライブラリである。

たとえば「statusというパラメータがpendingのときだけ文字色を灰色にしたい」といった実装をしたいとする。そういった場合、clsx を使用することで以下のように直接条件文の紐づけができる。

import clsx from 'clsx';
 
export default function InvoiceStatus({ status }: { status: string }) {
  return (
    <span
      className={clsx(
        'inline-flex items-center rounded-full px-2 py-1 text-sm',
        {
>          'bg-gray-100 text-gray-500': status === 'pending'
        },
      )}
    >
    // ...
)}

具体的には、clsx()の中にクラスを記述し、クラスに条件を付与したい場合は

  • オブジェクトとして定義する
  • キーに指定したいクラス、値に付与したい条件文を記述する

といった具合である。
詳しくは 公式ドキュメントを参照。

よつよつ

その他のスタイリング手段

これまでに上げた方法のほかに、

  • Sass を使用する
  • styled-jsxなどの CSS-in-JS ライブラリ
  • スタイル付きのコンポーネント

などを公式は推奨している。
ちなみに、Next.js 公式でもEmotionという CSS-in-JS ライブラリを提供してたりする。(参照
また、公式ドキュメントとしてスタイリング方法を解説した記事があるので、こちらも参考にしたい。

よつよつ

Next.js でのフォントの最適化

next/fontモジュールを使用する場合、アプリケーション内のフォントは自動的に最適化される。具体的には、ビルド時にフォントデータがダウンロードされ、画像や動画などのアセットとともにホストされるようになる。これにより、従来のような方法で Google Fonts などを使用した場合と比べ、追加の通信が行われないなどの利点がある。

このような最適化は、例えば Google が公開している CLS (Cumulative Layout Shift、累積レイアウトシフト) などの評価パラメータにも好影響をもたらす。

従来のフォント読込形式では、

  1. フォールバックフォント(機体のフォントが読み込めなかった場合の代替フォント)によってレンダリング
  2. カスタムフォントの読み込みが終わる
  3. フォントの置き換えを行う

という方法で表示を行ってきた。しかし、これでは2と3の間でレイアウトがずれてしまう場合があり、CLSスコアに悪影響をもたらしてしまう。そのため、Next.js ではこれの対策としてカスタムフォントを静的データとして扱うことにより、1~2の手順をなくしているわけである。

よつよつ

プライマリフォントを追加する

ここからは、実際に Google フォントをアプリケーションに追加していく。

まずは、/app/uifonts.tsを作成する。中身は以下の通り。

import { Inter } from 'next/font/google';
 
export const inter = Inter({ subsets: ['latin'] });

今回はInterというフォントを適用する。また、サブセット(プライマリフォントがカバーしていない文字に適用するフォント)にはlatinを指定する。

次に、ページ全体に対してフォントを適用する。具体的には、app/layout.tsxを以下のように編集する。

 import '@/app/ui/global.css';
>import { inter } from '@/app/ui/fonts';
 
 export default function RootLayout({
   children,
 }: {
   children: React.ReactNode;
 }) {
   return (
    <html lang="en">
>      <body className={`${inter.className} antialiased`}>{children}</body>
    </html>
   );
 }

bodyタグのクラスに対してフォントを追加している。ついでにantialiasedクラスをくっつけることでフォントを滑らかにしている(ここは任意)。

変更を保存したあと、開発サーバーを起動するか既に開いているページを更新すると、以下のようにフォントが変更した状態になる。

よつよつ

セカンダリフォントを使用する

同じように、ページの一部に適用するセカンダリフォントの設定をしていく。
まずはfonts.tsにてLusitanaを読み込む。

import { Inter, Lusitana } from 'next/font/google';

export const inter = Inter({ subsets: ['latin'] });
>export const lusitana = Lusitana({ subsets: ['latin'], weight: ["400", "700"]});

そしたら、app/page.tsxを開く。
今回はウェルカムメッセージの部分にLusitanaフォントを適用することにする。

ついでに、Lusitanaを使用しているためにコメントアウトしていたAcmeLogoタグのコメントアウトも外しておく。

 import styles from '@/app/ui/home.module.css';
 import AcmeLogo from '@/app/ui/acme-logo';
 import { ArrowRightIcon } from '@heroicons/react/24/outline';
 import Link from 'next/link';
>import { lusitana } from './ui/fonts';

 export default function Page() {
  return (
    <main className="flex min-h-screen flex-col p-6">
      <div className="flex h-20 shrink-0 items-end rounded-lg bg-blue-500 p-4 md:h-52">
>        <AcmeLogo />
      </div>
      <div className="mt-4 flex grow flex-col gap-4 md:flex-row">
        <div className="flex flex-col justify-center gap-6 rounded-lg bg-gray-50 px-6 py-10 md:w-2/5 md:px-20">
          <div className={styles.shape} />
>          <p className={`${lusitana.className} text-xl text-gray-800 md:text-3xl md:leading-normal`}>
            <strong>Welcome to Acme.</strong> This is the example for the{' '}
            ...
  );
 }

変更を保存し、ページを再読み込みすることで、以下のようにフォントが変わっていることを確認できる。

よつよつ

Next.js での画像の最適化

Web 上で画像を扱う際、以下のような問題がつきまとうことがある。

  • 画像をさまざまな画面サイズに合わせてスタイリングする必要がある
  • 様々なデバイスに対して画像サイズを指定する必要がある
  • 画像の読み込み時にレイアウトがずれるのを防ぐ必要がある
  • 負荷対策のためにユーザーのビューポート(視界)の外にある画像を遅延読み込みする必要がある

Next.js では、これらの問題をnext/imageコンポーネントによって最適化することができる。

よつよつ

Image コンポーネント

<Image>コンポーネントは、従来の HTML における <img> タグの拡張であり、画像を表示する効果に加え、以下のような特徴を持つ。

  • 画像読み込み前後でレイアウトがずれるのを防ぐ
  • ビューポートの大きさによって画像のサイズを調整する
  • デフォルトで画像を遅延読み込みする(見えていない部分の画像は後で読み込む)
  • WebP や AVIF などの最新のフォーマットをサポートしている場合は、その形式で提供する
よつよつ

ヒーローイメージを追加する

誤解の内容に説明しておくと、ヒーローイメージとは「Webサイトを表示したときに最初に視界に入るサムネイル」のことである。ヒーローの画像ではない。

今回は/public/hero-desktop.pngをホームページに配置していく。
/app/page.tsxを開き、Imageタグを記述する。

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
 import { lusitana } from '@/app/ui/fonts';
>import Image from 'next/image';
 
 export default function Page() {
  return (
    // ...
    <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
>      <Image
>        src="/hero-desktop.png"
>        width={1000}
>        height={760}
>        className="hidden md:block"
>        alt="Screenshots of the dashboard project showing desktop version"
>      />
    </div>
    //...
  );
 }

上記のように、src/public直下からのパスで記述することができる。
また、hiddenによってデフォルトでは非表示にし、md:blockで「デスクトップの画面幅の場合は画像を表示する」とすることで、モバイルでは画像を表示しないようにしている。これによって、スマートフォンなどから表示した際に思わぬレイアウト崩れを防げる。

変更を保存し、ページを表示すると、画像が表示されていることがわかる。

また、画面幅によって画像サイズが調整されていることも確認できる。

よつよつ

モバイル用の画像を追加してみる

上ではデスクトップ用のヒーローイメージのみ設定したが、モバイル用の画像も設定してみようと思う。
使用するのは/public/hero-mobile.png

具体的には、以下のようにモバイル用のImageコンポーネントを追加する。

import AcmeLogo from '@/app/ui/acme-logo';
import { ArrowRightIcon } from '@heroicons/react/24/outline';
import Link from 'next/link';
import { lusitana } from '@/app/ui/fonts';
import Image from 'next/image';
 
export default function Page() {
  return (
    // ...
    <div className="flex items-center justify-center p-6 md:w-3/5 md:px-28 md:py-12">
      {/* Add Hero Images Here */}
      <Image
        src="/hero-desktop.png"
        width={1000}
        height={760}
        className="hidden md:block"
        alt="Screenshots of the dashboard project showing desktop version"
      />
>      <Image
>        src="/hero-mobile.png"
>        width={560}
>        height={620}
>        className="block md:hidden"
>        alt="Screenshot of the dashboard project showing mobile version"
>      />
    </div>
    //...
  );
}

デスクトップ用ではクラスで表示を管理していたが、その条件を反転した形でコンポーネントに適用した。
再読み込みし、開発者ツールなどでスマホ幅から確認すると、以下のようにモバイル用の画像が表示されてることが確認できた。

よつよつ

ルーティング

Next.jsでのルーティングは、/appフォルダ内のフォルダ構造に基づいている。
たとえば/hoge/huga/piyoというルーティングに対応したページを作りたい際は、/app/hoge/huga/piyo/page.tsxを作成し、ページコンテンツを記述するだけでルーティングが完了する。

今回は/dashboardページを作る。そのため、/app/dashboard/page.tsxを作成する。
注意点として、/app/ui/dashboard/に作るわけではない。ここに作ってしまうと、実際のルーティングは/ui/dashboardとなってしまう。

ファイルが作成できたら、ページを表示するための最低限の記述を追加しておく。

export default function Page() {
  return <p>Dashboard Page</p>;
}

そしたら保存して、実際に`/dashboard``にアクセスしてみる。
以下のように表示されていれば問題なく表示できている。

よつよつ

演習:ダッシュボードページを作成する

ルーティングの練習として、以下を作成する。

  • 顧客ページ : /dashboard/customersに設置。<p>Customers Page</p>を返す。
  • 請求書ページ: /dashboard/invoicesに設置。<p>Invoices Page</p>を返す。
よつよつ

レイアウトの作成

次はサイドバーを実装していく。サイドバーのコンポーネントは/app/ui/dashboard/sidenav.jsxに用意されているようなので、これを使う。

/app/dashboard/layout.tsxを作成し、サイドバーコンポーネントを追加する。

 import SideNav from '@/app/ui/dashboard/sidenav';
 
 export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen flex-col md:flex-row md:overflow-hidden">
      <div className="w-full flex-none md:w-64">
        <SideNav />
      </div>
      <div className="flex-grow p-6 md:overflow-y-auto md:p-12">{children}</div>
    </div>
  );
 }

layout.tsxは、そのファイルがあるフォルダより下の階層のフォルダのページに対して適用される。今回の場合は/app/dashboardに設置したので、/app/dashboardの中にあるページすべてに適用される。

また、共通のコンポーネントを使っているページに遷移する際、わざわざそのコンポーネントが再レンダリングされることはない。これは Next.js の 部分レンダリング によるもので、ページ遷移時に更新のあるコンポーネントだけを置き換え、それ以外はそのまま使用することで、処理を削減している。

ページを更新し、サイドバーが問題なく表示されているかを確認する。

よつよつ

ルートレイアウト

プライマリフォントの適用に使用した/app/layout.tsxルートレイアウトと呼ばれ、すべてのページに共有されるレイアウトとなる。ここには、メタデータやグローバルスタイルなどを設置する。

よつよつ

ページ間を移動する

ユーザーがほかのページへシームレスに移動できるようにするためには、ページ内に他ページへのリンクを設置する必要がある。従来の HTML を使った方法ではaタグを用いることでリンクを実装していたが、 Next.js ではLinkコンポーネントを使用することができる。

ここでは、ナビゲーションにいくつかのリンクを追加する。
/app/ui/dashboard/nav-links.tsxを開き、aタグをLinkコンポーネントに置き換える。

import {
  UserGroupIcon,
  HomeIcon,
  DocumentDuplicateIcon,
} from '@heroicons/react/24/outline';
>import Link from "next/link";

// Map of links to display in the side navigation.
// Depending on the size of the application, this would be stored in a database.
const links = [
  { name: 'Home', href: '/dashboard', icon: HomeIcon },
  {
    name: 'Invoices',
    href: '/dashboard/invoices',
    icon: DocumentDuplicateIcon,
  },
  { name: 'Customers', href: '/dashboard/customers', icon: UserGroupIcon },
];

export default function NavLinks() {
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
<         <a
>         <Link
            key={link.name}
            href={link.href}
            className="flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3"
          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
<          </a>
>          </Link>
        );
      })}
    </>
  );
}

変更を保存してリロードし、/dashboardにアクセスしてサイドバーが機能していることを確認する。

実際に触ってみると一目瞭然かと思うが、ページ遷移が驚くほど速い。これは Next.js によるいくつかの最適化処理が関連している。

よつよつ

自動コード分割

Next.js では、ビルド時にコードを自動的に小さなチャンク(かたまり)に分解し、各ページにアクセスした際に必要なチャンクだけを読み込むようになっている。これにより、ユーザーにとって不要な処理を削減し、パフォーマンスを向上させている。
また、運用環境では、ユーザーのビューポート(視界)にLinkコンポーネントが移るたびに、対象となるページのコードを事前に読み込んでいる。そのため、ユーザーからは「クリックしたら瞬時に遷移した」ように見える。

よつよつ

アクティブなページを強調表示する

ここでは、アクティブになっているページをサイドバーで青色で強調表示する実装を行う。たとえば/dashboardを表示している場合、対応するサイドバーの「Home」の部分が以下のように青色でハイライトされるといった具合である。

これを実現するためには、ユーザーが現在開いているページを取得する必要がある。 Next.js では、usePathname()を使用することで取得することができる。

/app/ui/dashboard/nav-links.tsxを開き、usePathname()のインポート文を追加する。
また、usePathname()"use client";の宣言の下でのみ機能するので、これを頭に追記しておく。

>'use client';
 
 import {
  UserGroupIcon,
  HomeIcon,
  InboxIcon,
 } from '@heroicons/react/24/outline';
 import Link from 'next/link';
>import { usePathname } from 'next/navigation';
 
// ...

そしたら、コンポーネント内で現在のパスを取得し、同じパスを指すLinkコンポーネントがある場合はハイライトを追加するように変更する。

>import clsx from 'clsx';
 
export default function NavLinks() {
  const pathname = usePathname();
 
  return (
    <>
      {links.map((link) => {
        const LinkIcon = link.icon;
        return (
          <Link
            key={link.name}
            href={link.href}
>            className={clsx(
>              'flex h-[48px] grow items-center justify-center gap-2 rounded-md bg-gray-50 p-3 text-sm font-medium hover:bg-sky-100 hover:text-blue-600 md:flex-none md:justify-start md:p-2 md:px-3',
>              {
>                'bg-sky-100 text-blue-600': pathname === link.href,
>              },
>            )}
>          >
            <LinkIcon className="w-6" />
            <p className="hidden md:block">{link.name}</p>
          </Link>
        );
      })}
    </>
  );
}

変更を保存してページを更新し、問題なくハイライトが追加されることを確認する。

よつよつ

データベースをセットアップする

ここでは、@vercel/postgresを使ってデータベースを構築していく。

下準備として、リポジトリを GitHub 上に公開しておく必要がある。
これにより、vercel 側からかんたんにリポジトリを扱うことができる。

そしたら、vercel にアクセスしてログインする。アカウントがない場合は作成する。
https://vercel.com/signup

ログインに成功すると、以下の画面に移行する。

ここで、さきほど GitHub に公開したリポジトリを選択して「Import」をクリックする。

設定画面に遷移するので、「Deploy」をクリック。

そしたらデプロイ作業が始まる。
しばらくするとデプロイ完了を知らせる画面に遷移する。

これでデプロイは完了され、今後は main ブランチに変更があるたびにこのページも更新される。
サムネイルをクリックすることで、開発環境と同じように表示されることを確認しておこう。

よつよつ

vercel 上に Postgres データベースを作成する

今回は Postgres というデータベースを扱っていく。
vercel のダッシュボードからプロジェクトを選択し、プロジェクトのダッシュボードへ移動する。

そしたら「ストレージ」タブを選択し、「Connect Store」->「Create New」を選択。

表示されたタブの中から「Postgres」を選択し、「Continue」を押す。

リージョン選択画面に移動するので、「Create」を押す。
応答速度が気になる場合はシンガポールなどに変更しておくといいかも。

作成が完了したら、「Connect Store」->「Continue」->「Connect」を押す。

そしたら、プロジェクトとデータベースの連携が完了する。

今後の作業のために、環境変数をメモしておく。
.env.localタブを開き、「Copy Snippet」を押す。

これにより、クリップボードに環境変数が保存される。

そしたらエディターに戻り、.env.example.envにリネームする。
そして、3-9行目の空欄になっている部分を先ほどの情報で置き換える。
(セキュリティの都合で伏せていますが、実際はURLなどの値が入ります)

# Copy from .env.local on the Vercel dashboard
# https://nextjs.org/learn/dashboard-app/setting-up-your-database#create-a-postgres-database
>POSTGRES_URL="************"
>POSTGRES_PRISMA_URL="************"
>POSTGRES_URL_NO_SSL="************"
>POSTGRES_URL_NON_POOLING="************"
>POSTGRES_USER="************"
>POSTGRES_HOST="************"
>POSTGRES_PASSWORD="************"
>POSTGRES_DATABASE="************"

# `openssl rand -base64 32`
AUTH_SECRET=
AUTH_URL=http://localhost:3000/api/auth

最後にVercel Postgres SDKをインストールしておく。

npm i @vercel/postgres
よつよつ

初期データを挿入してみる

/script/seeds.jsには、初期データとそれを挿入するための SQL が格納されている。これを上で構築したデータベースに挿入してみる。

package.jsonscriptsに以下の行を追加する。

"scripts": {
  "build": "next build",
  "dev": "next dev",
  "start": "next start",
>  "seed": "node -r dotenv/config ./scripts/seed.js"
},

変更を保存したら、実際に実行してみる。

npm run seed

問題なく実行されれば、以下のようにログが流れる。

$ npm run seed

> seed
> node -r dotenv/config ./scripts/seed.js

Created "users" table
Seeded 1 users
Created "customers" table
Seeded 10 customers
Created "invoices" table
Seeded 15 invoices
Created "revenue" table
Seeded 12 revenue
よつよつ

データベースを探索する

さきほど挿入したデータを、データベース側から確認してみる。
まずは、vercel 上のプロジェクトのダッシュボードに移動し、「Storage」タブを開く。

すこし下にスクロールすると、「Data」という項目がある。
ここでいくつかのテーブルが初期化されているのを確認できる。

たとえばcustomersテーブルを選択してみると、いくつかのサンプルデータが挿入されているのも確認できる。
これらはさっきのseeds.jsで定義されていたデータと同じである。

よつよつ

クエリを実行してみる

データベースのダッシュボードから「Data」の中にある「Query」を選択すると、データベースに対してクエリを実行できる。

ここでは、例として「請求書の数がちょうど 666 個の顧客名」を表示するクエリが取り上げられている。
以下のコードをコピーして、クエリの入力欄に張り付ける。

SELECT invoices.amount, customers.name
FROM invoices
JOIN customers ON invoices.customer_id = customers.id
WHERE invoices.amount = 666;

「Run Query」を押すと、結果が下に表示される。

よつよつ

データを取得する

データベースを介してデータを扱うには、いくつかの手段が考えられる。

API

API が提供されているサービスを利用している際、APIとしてデータアクセスを切り出すことができる。先で開設した通り、Next.jsではフォルダ構造を利用したルートハンドラが採用されているため、比較的容易に実装することができる。

クエリ

データベースに対してクエリを送信することでデータを取得する一般的な手法である。Postgres のように RDB である場合、Prisma などの ORMライブラリ を採用することでより簡単に実装できる。
注意点として、クライアント側からデータを取得する処理を実行する必要がある場合は、クエリを直接書くべきではない。データベースの構造などの情報が流出してしまう危険性があるためである。こういった場合は、サーバー側に API エンドポイントを用意し、そこに対してアクセスする形でデータを受け渡すなどのアプローチが考えられる。

よつよつ

サーバーコンポーネントを使用したデータの取り扱い

Next.js は、デフォルトで React Server Component を使用する。これにより、

  • useEffectuseStateを使用することなくasync/awaitが使用できる
  • 処理がサーバー上で行われるため、ロジックをクライアントに公開することなくデータを送信できる
  • 上記の理由で、安全性を考えた API レイヤを準備する必要がなくなる
よつよつ

SQL を使う

ここでは、Vercel Postgres SDKを使用する。これは、vercel上のデータベースに対し、 SQL インジェクションから保護した状態でアクセスするのに役立つライブラリとなる。

/app/lib/data.tsを確認すると、@vercel/postgresをインポートしているのを確認できる。

import { sql } from '@vercel/postgres';
 
// ...

ここでインポートされているsql関数を利用して、データベースに対してクエリを実行することができる。
例えば収益データを取得するfetchRevenueでは、以下の行で SQL が記述されていることが確認できる。

const data = await sql<Revenue>`SELECT * FROM revenue`;

今回はすでに例としていくつかの関数が実装されているため、かわりにこの関数を利用してアクセスを試みる。

よつよつ

ダッシュボードにデータを表示する

それでは、実際に実装に移る。今回は、ダッシュボード上にいくつかのデータを取得して表示させる。

まずは、/app/dashboard/page.tsxを以下のコードで置換する。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        {/* <Card title="Collected" value={totalPaidInvoices} type="collected" /> */}
        {/* <Card title="Pending" value={totalPendingInvoices} type="pending" /> */}
        {/* <Card title="Total Invoices" value={numberOfInvoices} type="invoices" /> */}
        {/* <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        /> */}
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        {/* <RevenueChart revenue={revenue}  /> */}
        {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
      </div>
    </main>
  );
}

まず、コンポーネントが非同期であることが確認できる。

export default async function Page() {

これによって、データの取得が可能になる。
また、いくつかのコメントアウトされたコンポーネントも確認できる。このコンポーネントに対してデータを与えることで、表示を実装していく。

よつよつ

RevenueChart

まずは、利益を表示するRevenueChartコンポーネントを動作させてみる。

/app/dashboard/page.tsx/app/lib/data.tsからfetchRevenue関数をインポートし、コンポーネント内で利益データを取得する。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
>import { fetchRevenue } from '@/app/lib/data';
 
export default async function Page() {
>  const revenue = await fetchRevenue();
  // ...
}

これでRevenueChartコンポーネントが使えるようになったので、コメントアウトを外す。

<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
>  <RevenueChart revenue={revenue}  />
  {/* <LatestInvoices latestInvoices={latestInvoices} /> */}
</div>

また、/app/ui/dashboard/revenue-chart.tsx内のコメントアウトも外しておく。(コードが長いので割愛)

変更を保存し、ページを再読み込みすると、グラフが表示されていることを確認できる。

LatestInvoices

次に、最新の請求書を5件表示するLatestInvoicesの実装に移る。
先ほどと同様、関数は/app/lib/data.tsfetchLatestInvoicesとして実装されている。これを/app/dashboard/page.tsxにインポートする。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
>import { fetchRevenue, fetchLatestInvoices } from '@/app/lib/data';
 
export default async function Page() {
  const revenue = await fetchRevenue();
>  const latestInvoices = await fetchLatestInvoices();
  // ...
}

そして、これも同じようにLatestInvoicesコンポーネントのコメントを解除する。また、/app/ui/dashboard/latest-invoices.tsxのコメントアウトも解除しておくこと。

<div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
  <RevenueChart revenue={revenue}  />
>  <LatestInvoices latestInvoices={latestInvoices} />
</div>

変更を保存し、ページをリロードする。最新の請求書が表示されていることを確認しよう。

SQL(もしくは ORM )を使用することで、処理の一部をデータベース側に委任することができ、結果としてメモリ負荷を分散することが可能となる。
例えば、すべてのデータを取得し、JavaScript を用いてデータを処理するとする。この処理にはデータ相当のメモリ容量が必要になり、通信量も膨大になる、それに伴って処理も遅くなるなどデメリットが大きい。それに比べ、洗練された SQL によってクエリを実行することは、不要な処理を取り除くのにとてもよいアプローチとなる。

Card

最後にCardコンポーネントを実装する。カードには以下の情報が含まれる。

  • 請求書の合計金額
  • 保留中の請求書の合計金額
  • 請求書の合計数
  • 顧客の総数

さて、データを取得する方法を考える。どうやら、/app/lib/data.tsfetchCardDataという関数が実装されているようである。

export async function fetchCardData() {

念のため、返り値の構造を見ておく。どうやら、必要な値をオブジェクト形式で返しているようである。

return {
  numberOfCustomers,
  numberOfInvoices,
  totalPaidInvoices,
  totalPendingInvoices,
};

使用する関数の確認が済んだので、さきほどと同じように/app/dashboard/page.tsxにインポートし、コンポーネント内でデータを取得する。

import {
  fetchRevenue,
  fetchLatestInvoices,
>  fetchCardData,
} from '@/app/lib/data';
 
export default async function Page() {
  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
>  const {
>    numberOfInvoices,
>    numberOfCustomers,
>    totalPaidInvoices,
>    totalPendingInvoices,
>  } = await fetchCardData();

続いて、Cardコンポーネントのコメントアウトを解除する。/app/ui/card.tsxのコメントアウトを解除するのを忘れないこと。

 <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
>  <Card title="Collected" value={totalPaidInvoices} type="collected" />
>  <Card title="Pending" value={totalPendingInvoices} type="pending" />
>  <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
>  <Card
>    title="Total Customers"
>    value={numberOfCustomers}
>    type="customers"
>  />
 </div>

変更を保存し、ページをリロードする。統計値を掲示するカードが表示されることを確認する。

よつよつ

リクエスト・ウォーターフォール

Next.js では、各リクエストは並列には処理されず、実行された順番に処理される。これをリクエスト・ウォーターフォールと呼ぶ。
たとえば、先ほどのコードでは、fetchRevenuefetchLatestInvoicesfetchCardDataという順に通信が行われ、各リクエストはひとつ前のリクエストの完了を待っていた。

const revenue = await fetchRevenue();
const latestInvoices = await fetchLatestInvoices(); // fetchRevenue() が終わったらスタート
const {
  numberOfInvoices,
  numberOfCustomers,
  totalPaidInvoices,
  totalPendingInvoices,
} = await fetchCardData(); // fetchLatestInvoices() が終わったらスタート

この動きは、システムのスムーズな動作に悪影響になる可能性がある。とくに負荷の大きい通信などの場合は、UXを損ねてしまうことも大いに考えられる。

並列処理でデータを取得する

そこで、データの取得を並列処理で行う方法を学ぶ。

JavaScript では、Promise.all()Promise.allSettled()で並列処理を実現できる。
実際に、さきほど使用したfetchCardData()の内部では、カードに必要な各データを並列で取得している。

const invoiceCountPromise = sql`SELECT COUNT(*) FROM invoices`;
const customerCountPromise = sql`SELECT COUNT(*) FROM customers`;
const invoiceStatusPromise = sql`SELECT
     SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) AS "paid",
     SUM(CASE WHEN status = 'pending' THEN amount ELSE 0 END) AS "pending"
     FROM invoices`;

const data = await Promise.all([
   invoiceCountPromise,
  customerCountPromise,
  invoiceStatusPromise,
]);
よつよつ

静的レンダリング

静的レンダリング では、データの取得とレンダリングがビルド時・再検証時(キャッシュを消去し、最新のデータを再取得すること)に行われる。取得したデータは CDN に配布・キャッシュできる。

このレンダリングによって、以下のメリットが発生する。

  • 表示の高速化:事前にレンダリングされたコンテンツを送信することで、従来よりコンテンツの表示が早くなる。
  • サーバー負荷の軽減:ユーザーのリクエストごとにレンダリングする必要がなくなり、負荷が軽減される。
  • SEO:事前にコンテンツをロードしておくことで、検索エンジンにとってインデックス付けがしやすくなる。

この方法は、記事や商品情報などの静的なデータに適している。逆に言えば、ダッシュボードのような動的なコンテンツには適さない可能性が高い。

動的レンダリング

動的レンダリング では、リクエスト時にデータの取得とレンダリングを行う。

このレンダリングによって、以下のメリットが発生する。

  • リアルタイムなデータの表示:ユーザーのリクエスト時にデータを取り寄せる都合上、リアルタイムなデータの表示に適している。
  • ユーザー固有のコンテンツ:ユーザーによって異なる値を表示するダッシュボードなどのコンテンツに対して対応できる。
  • リクエスト時の情報:Cookie や URL パラメータなどの情報を扱う処理は、動的レンダリングで扱うことが推奨される。
よつよつ

ダッシュボードを静的にする

実際に、ダッシュボードのデータを取得する処理を最適化してみる。

まず、/app/lib/data.tsを開き、next/cacheからunstable_noStorenoStoreとしてインポートする。これは「このリクエストはキャッシュしないでね」ということを示すための関数だと思ってよい。

>import { unstable_noStore as noStore } from 'next/cache';

そしたら、各関数のはじめにnoStore()を追加する。
注意点として、すべての関数に追加するわけではない。getUserなどのキャッシュが有効な関数に関しては追加していない。

export async function fetchRevenue() {
  // Add noStore() here to prevent the response from being cached.
  // This is equivalent to in fetch(..., {cache: 'no-store'}).
>  noStore();
 
  // ...
}
 
export async function fetchLatestInvoices() {
>  noStore();
  // ...
}
 
export async function fetchCardData() {
>   noStore();
  // ...
}
 
export async function fetchFilteredInvoices(
  query: string,
  currentPage: number,
) {
>  noStore();
  // ...
}
 
export async function fetchInvoicesPages(query: string) {
>  noStore();
  // ...
}
 
export async function fetchFilteredCustomers(query: string) {
>  noStore();
  // ...
}
 
export async function fetchInvoiceById(query: string) {
>  noStore();
  // ...
}

これで、各リクエストの不要なキャッシュを削減することに成功した。

よつよつ

重いデータ取得処理をシミュレーションしてみる

仮に、どれかのデータ取得処理が重かった場合、ページの表示はどうなるだろうか。確認してみよう。

/app/lib/data.tsを開き、fetchRevenue()含まれるコメントを解除する。これには、意図的に処理を遅延させるためのsetTimeoutが含まれている。

>console.log('Fetching revenue data...');
>await new Promise((resolve) => setTimeout(resolve, 3000));
 
 const data = await sql<Revenue>`SELECT * FROM revenue`;
 
>console.log('Data fetch completed after 3 seconds.');

変更を保存し、ページをリロードすると、ページの表示が遅いことが確認できる。

リロード時のターミナルの表示も確認しよう。処理の遅さが目に見えて確認できる。

これが動的レンダリングの弱点でもある。動的レンダリングを使用したとき、アプリケーションは最も遅い処理と同じ速度で動作する。

よつよつ

ストリーミング

ストリーミング は、ページを「チャンク」(かたまり)に分解し、準備が整ったチャンクから段階的にクライアントに送信する技術である。
先ほどの処理では、一部の処理が遅いために、ページ全体の更新が止まってしまった。ストリーミングを利用すれば、処理を分割し、表示できる部分のみを先に表示することができる。

Next.js でストリーミングを実装するには、ふたつの手段がある。

  • ページに適用: loading.tsxを用意する
  • コンポーネントに適用: <Suspense>コンポーネントを使用する
よつよつ

ページ全体のストリーミング、loading.tsx

それでは、実際に実装してみる。
/app/dashboardloading.tsxを作成し、以下のように初期化する。

export default function Loading() {
  return <div>Loading...</div>;
}

変更を保存し、ページをローディングする。すると、ロード時にページが硬直せず、ローディングメッセージが表示されていることがわかる。

スケルトンスクリーンの追加

ローディングメッセージの代わりに、ローディング中であることを示す スケルトンスクリーン を表示する。
スケルトンスクリーンとは、コンテンツの骨組みのみを表示してローディング中であることを伝える手法である。

/app/dashboard/loading.tsxを開き、<DashboardSkeleton>コンポーネントを追加する。

>import DashboardSkeleton from '@/app/ui/skeletons';
 
 export default function Loading() {
>  return <DashboardSkeleton />;
}

変更を保存し、ページをロードする。以下のようにスケルトンが表示されていることを確認する。

loading.tsxは、ファイルがあるフォルダ内部のすべてのページに適用される。そのため、/dashboard/invoices/dashboard/customersに対しても表示される。
これを避けるため、ルートグループ を使用する。これは、()で囲まれたフォルダ名のフォルダを作成することで、URL 構造に影響を与えることなくフォルダを作成するための機能である。

実際に作成してみる。/app/dashboard(overview)フォルダを作成する。もちろん、かっこも含めた名前であるため、省略しないようにすること。
そしたら、/app/dashboard/page.tsx/app/dashboard/loading.tsxをフォルダの中に入れる。

VSCode を使用しているなら、移動時に「インポートを更新しますか?」という表示が出るので、「更新する」を押しておく。こうすることで、パスを自動的に更新してくれる。
それ以外のエディタの場合は、インポート文のパスを修正しておくのを忘れないようにする。

これで、スケルトンはダッシュボードページのみを対象として表示されるようになった。

よつよつ

コンポーネントのストリーミング、Suspense

ページ全体をストリーミングする代わりに、React の Suspense コンポーネントを使用すれば、コンポーネント単位でストリーミングができる。
Suspenseを使用すると、表示できる条件が満たされるまでレンダリングを延期できる。そのあいだ、Suspenseコンポーネントのfallback属性に渡したコンポーネントが表示される。

それでは実装していく。
まず、ストリーミングの対象にしたい処理をいったん削除する。今回は処理が重いfetchRevenueの行を対象にする。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import {
  fetchCardData,
  fetchLatestInvoices,
<  fetchRevenue,
} from '../../lib/data';
 
export default async function Page() {
<  const revenue = await fetchRevenue();
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    // ...
  );
}

次に、Suspenseと、RevenueChartのスケルトンになるRevenueChartSkeletonをインポートする。そしてRevenueChartSuspenseで囲い、fallbackRevenueChartSkeletonを置く。
また、RevenueChartrevenue属性は消しておく。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import { fetchLatestInvoices, fetchCardData } from '@/app/lib/data';
>import { Suspense } from 'react';
>import { RevenueChartSkeleton } from '@/app/ui/skeletons';
 
export default async function Page() {
  const latestInvoices = await fetchLatestInvoices();
  const {
    numberOfInvoices,
    numberOfCustomers,
    totalPaidInvoices,
    totalPendingInvoices,
  } = await fetchCardData();
 
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
        <Card title="Collected" value={totalPaidInvoices} type="collected" />
        <Card title="Pending" value={totalPendingInvoices} type="pending" />
        <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
        <Card
          title="Total Customers"
          value={numberOfCustomers}
          type="customers"
        />
      </div>
      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
>        <Suspense fallback={<RevenueChartSkeleton />}>
>          <RevenueChart />
>        </Suspense>
        <LatestInvoices latestInvoices={latestInvoices} />
      </div>
    </main>
  );
}

これでストリーミングの準備はできた。最後に、/app/ui/dashboard/revenue-chart.tsxを開き、利益を取得する処理を追加する。

import { generateYAxis } from '@/app/lib/utils';
import { CalendarIcon } from '@heroicons/react/24/outline';
import { lusitana } from '@/app/ui/fonts';
>import { fetchRevenue } from '@/app/lib/data';
 
// ...
 
>export default async function RevenueChart() { // add async
>  const revenue = await fetchRevenue();
 
  const chartHeight = 350;
  const { yAxisLabels, topLabel } = generateYAxis(revenue);
 
  if (!revenue || revenue.length === 0) {
    return <p className="mt-4 text-gray-400">No data available.</p>;
  }
 
  return (
    // ...
  );
}

変更を保存し、ページをリロードする。利益を表示するチャートが、ロード中はスケルトンになっているのを確認する。

よつよつ

LatestInvoices をストリーミングする

同じように、LatestInvoices もストリーミングする。
まずはLatestInvoicesSuspenseで囲む。fallbackにはLatestInvoicesSkeletonを指定する。
LatestInvoicesに指定していたinvoices属性は削除しておく。

      <div className="mt-6 grid grid-cols-1 gap-6 md:grid-cols-4 lg:grid-cols-8">
        <Suspense fallback={<RevenueChartSkeleton />}>
          <RevenueChart />
        </Suspense>
>        <Suspense fallback={<LatestInvoicesSkeleton />}>
>          <LatestInvoices />
>        </Suspense>
      </div>

これに伴って、コンポーネントでデータを取得する処理も削除しておく。

import { Card } from '@/app/ui/dashboard/cards';
import RevenueChart from '@/app/ui/dashboard/revenue-chart';
import LatestInvoices from '@/app/ui/dashboard/latest-invoices';
import { lusitana } from '@/app/ui/fonts';
import {
  fetchCardData,
<  fetchLatestInvoices,
} from '../../lib/data';
import { Suspense } from 'react';
import { LatestInvoicesSkeleton, RevenueChartSkeleton } from '@/app/ui/skeletons';

export default async function Page() {
<  const latestInvoices = await fetchLatestInvoices();

次に、/app/ui/dashboard/latest-invoices.tsxに移る。
さきほど削除したデータの取得処理をここに移動する。

>import { fetchLatestInvoices } from '@/app/lib/data';

>export default async function LatestInvoices() {   // 引数を削除しておく
>  const latestInvoices = await fetchLatestInvoices();

// ...

変更を保存し、ページをリロードする。
ページ下部の請求一覧がスケルトンになっていることを確認できる(GIF では確認のためsetInterval() を追加しています)。

よつよつ

Card をストリーミングする

次に、Cardコンポーネントをストリーミングする。
流れはほかのコンポーネントと同じだが、Cardコンポーネントは複数使用されている。そのため、それぞれに対して適用するのではなく、CardWrapperというカードを束ねたコンポーネントを使用することとする。

/app/dashboard/page.tsxを以下のように変更する。

>import CardWrapper from '@/app/ui/dashboard/cards';
// ...
import {
  RevenueChartSkeleton,
  LatestInvoicesSkeleton,
>  CardsSkeleton,
} from '@/app/ui/skeletons';
 
export default async function Page() {
  return (
    <main>
      <h1 className={`${lusitana.className} mb-4 text-xl md:text-2xl`}>
        Dashboard
      </h1>
      <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
>        <Suspense fallback={<CardsSkeleton />}>
>          <CardWrapper />
>        </Suspense>
      </div>
      // ...
    </main>
  );
}

次に/app/ui/dashboard/cards.tsxに移動し、CardWrapperコンポーネントのコメントアウトを解除する。fetchCardDataのインポートも忘れずにやっておく。

// ...
>import { fetchCardData } from '@/app/lib/data';
 
// ...
 
export default async function CardWrapper() {
>  const {
>    numberOfInvoices,
>    numberOfCustomers,
>    totalPaidInvoices,
>    totalPendingInvoices,
>  } = await fetchCardData();
 
  return (
    <>
      <Card title="Collected" value={totalPaidInvoices} type="collected" />
      <Card title="Pending" value={totalPendingInvoices} type="pending" />
      <Card title="Total Invoices" value={numberOfInvoices} type="invoices" />
      <Card
        title="Total Customers"
        value={numberOfCustomers}
        type="customers"
      />
    </>
  );
}

変更を保存し、ページをリロードする。カードの部分にスケルトンが表示されることを確認する。
(確認のためsetIntervel()を使用しています)

よつよつ

静的コンテンツと動的コンテンツを結合する

現在、ルート内で動的関数(noStore()など)を使用すると、ルート全体が動的になる。
このように、現行のほとんどのWebアプリは、アプリケーション・ルートに対して静的・動的いずれかのレンダリング方法を選択することで構築されている。

しかし、「部分的に動的レンダリングしたい」といったユースケースはたびたび発生する。たとえばここまで作ってきたダッシュボードは、

  • 静的:サイドバー
  • 動的:カード、チャート、リスト

というように、静的・動的なコンテンツのどちらをも持っていることがわかる。

部分プリレンダリング

こういった需要にこたえるため、 Next.js には 部分的なプリレンダリング の機能が試験的に導入されている。これは、一部のコンポーネントを動的に保ちつつ、静的なコンポーネントから分離してレンダリングする機能である。これにより、ユーザーがアクセスしたとき、

  • 静的コンテンツが高速で提供される
  • 動的コンテンツがあるべき場所は、コンテンツがロードされるまで予約される(スケルトンなどが表示できる)
  • 動的コンテンツがロードし終えたら、順次穴にはめ込む形で表示される

といった動作をする。

よつよつ

部分プリレンダリングの実現

では、その部分プリレンダリングを行うにはどうすればよいか。これには、React 18 より追加された Concurrent Features(同時機能) が必要となる。これは Suspense コンポーネントに対して機能を追加し、非同期な要素をレンダリング時に分離させ、静的なコンテンツのみを優先して表示することを実現する。
要するに、さきほど行ったようなSuspenseコンポーネントを用いた実装がこれにあてはまる。

注意点として、「Suspenseで囲めば動的になる」わけではない。さきほどnoStore()で手動でキャッシュを止めていたように、あくまでSuspenseは「ルートの静的・動的の境目」として機能することを意識することが重要である。

よつよつ

検索とページネーション

ここでは、/invoicesページを実装していく。特に、検索とページネーションに焦点を置いて解説していく。
ページネーション とは、複数の要素をページに分割して表示するとき、それらのページを遷移するために使用するコンポーネントである。

それでは実装に移る。
/app/dashboard/invoices/page.tsxに移動し、次のコードを張り付ける。元のコードは削除してよい。

import Pagination from '@/app/ui/invoices/pagination';
import Search from '@/app/ui/search';
import Table from '@/app/ui/invoices/table';
import { CreateInvoice } from '@/app/ui/invoices/buttons';
import { lusitana } from '@/app/ui/fonts';
import { InvoicesTableSkeleton } from '@/app/ui/skeletons';
import { Suspense } from 'react';
 
export default async function Page() {
  return (
    <div className="w-full">
      <div className="flex w-full items-center justify-between">
        <h1 className={`${lusitana.className} text-2xl`}>Invoices</h1>
      </div>
      <div className="mt-4 flex items-center justify-between gap-2 md:mt-8">
        <Search placeholder="Search invoices..." />
        <CreateInvoice />
      </div>
      {/*  <Suspense key={query + currentPage} fallback={<InvoicesTableSkeleton />}>
        <Table query={query} currentPage={currentPage} />
      </Suspense> */}
      <div className="mt-5 flex w-full justify-center">
        {/* <Pagination totalPages={totalPages} /> */}
      </div>
    </div>
  );
}

見ての通り、いくつかのコンポーネントがコメントアウトされているのが見える。これらを実装していく。

よつよつ

URL パラメータを使用する

検索フォームは、URL パラメータとしてキーワードを渡すことで実装する。ユーザーがクライアント上で請求書を検索すると、URL パラメータが更新され、それをサーバー上で取得、新しいデータを使用してテーブルを再レンダリングする、といった流れになる。
URL パラメータを利用することで、以下のような利点が生まれる。

  • 検索内容を含めたURLが発行・ブックマーク可能になる
  • URLにユーザーの行動が記されるため、行動の追跡が容易になる