💭

Next.js 13 app directory で記事投稿サイトを作ってみよう

2023/01/15に公開約33,500字

Next.js 13 から新たに app directory という機能が追加されました。これは従来の pages ディレクトリとは異なるレイアウトシステムです。app ディレクトリには以下のような特徴があります。

  • ルーティング:pages ディレクトリではページのルーティングはファイル名によって決まっていました。例えば pages/about.js というファイルは /about というパスに対応します。app ディレクトリではルーティングに対応するファイルは page.js という固定の名前になります。/about というパスに対応するファイルは app/about/page.js という名前になるのです。page.js 以外にも共通されたレイアウトを担当する layout.js、ローディング UI を表示する loading.js などさまざまな特殊なファイルが存在します。
  • レンダリング:app ディレクトリ内のコンポーネントはデフォルトで Server Component として扱われます。Client Component として扱いたい場合には "use client" をファイルの先頭で宣言する必要があります。
  • データフェッチング:従来の getStaticPropsgetServerSidePropsapp ディレクトリでは使えません。代わりに Server Component で async/await を使用してデータを取得できます。またデータの取得時にキャッシュやリクエストの重複排除を活用するため fetch API を利用します。
  • キャッシュ:fetch API 用いてデータを取得する際にはデフォルトで Next.js による HTTP キャッシュが有効になっています。またクライアントサイドでのキャッシュにより、クライアントでのナビゲーションでは余分なリクエストが発生しません。

app ディレクトリを使うことで、直感的なレイアウトシステムだけでなく、パフォーマンスの向上が見込めます。この記事では app directory を使って簡単な記事投稿サイトを作り、新しい機能の特徴を体験してみましょう。

完成後のコードは以下のリポジトリにあります。

https://github.com/azukiazusa1/nextjs-app-dir-example/tree/complete

開発環境の準備

まずは、Next.js アプリケーションを作成します。azukiazusa1/nextjs-app-dir-example のレポジトリからクローンしていただくと、バックエンドの API が準備済みの状態になっています。

git colone https://github.com/azukiazusa1/nextjs-app-dir-example.git

自分で作成する場合は、以下のコマンドを実行してください。

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

app ディレクトリを使用するためには next.confg.jsexperimental: { appDir: true } を追加する必要があります。以下のような設定となっているか確認してください。

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

module.exports = nextConfig;

以下のコマンドでパッケージをインストールして、開発環境を起動してみましょう。

npm run install
npm run dev

http://localhost:3000/ にアクセスすると、以下のような画面が表示されます。

Next.js で開発環境を起動したデフォルトで表示されるブラウザの画面

app ディレクトリの概要

まずは app ディレクトリに初期状態では以下のファイルが存在します。

  • page.tsx:ルーティングに対応する UI を定義するファイル。
  • layout.tsx:アプリケーションのルートレイアウト。すべてのページ共通で使われるナビゲーションヘッダーなどに加えて <html> タグや <body> タグを設定する。
  • header.tsx:このファイル内に記述した内容は <head> タグに挿入される。

page.tsx ファイルの編集

page.tsx ファイルを編集して表示が切り替えることを確認してみましょう。

app/page.tsx
export default function Home() {
  return (
    <div>
      <h1>新着記事</h1>
      <ul>
        <li>記事1</li>
        <li>記事2</li>
        <li>記事3</li>
      </ul>
    </div>
  )
}

ファイルを編集した後 http://localhost:3000/ にアクセスすると、以下のように表示が切り替わっていることが確認できます。

app/page.tsx で変更した内容がブラウザに反映されている

続いて /articles/{slug} というパスへアクセスしたときに、記事の詳細を表示するページを作ってみましょう。app ディレクトリの構造と URL のパスがマッピングされているので、app/articles/[slug] というディレクトリを作成します。

mkdir app/articles/[slug]

作成した URL パスの UI を担当するファイルは page.tsx という名前で作成します。

touch app/articles/[slug]/page.tsx

app/articles/[slug]/page.tsx ファイルを以下のように編集しましょう。動的なパスの値(slug)を取得するために、引数から params を受け取っています。

app/articles/[slug]/page.tsx
export default function Article({ params }: { params: { slug: string } }) {
  return (
    <div>
      <h1>記事の詳細</h1>
      <p>記事のスラッグ: {params.slug}</p>
    </div>
  );
}

http://localhost:3000/articles/next-js-app-dir-tutorial にアクセスすると、上記で編集したファイルの内容が表示されていることが確認できます。

app/articles/[slug]/page.tsx で変更した内容がブラウザに反映されている

layout.tsx ファイルの編集

Layout も編集してみましょう。app/layout.tsx ファイルは Root Layout と呼ばれています。Root Layout はすべてのページに対して適用されるレイアウトです。Next.js は <html><body> タグを自動的に生成しないので、app/layout.tsx ファイルで必ず定義する必要があります。

app/layout.tsx
import Link from "next/link";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="ja">
      <head />
      <body>
        <header>
          <h1>
            <Link href="/">ブログ</Link>
          </h1>
          <Link href="/articles/new">記事を書く</Link>
        </header>
        {children}
        <footer>
          <small>© 2023 azukiazusa</small>
        </footer>
      </body>
    </html>
  );
}

http://localhost:3000/http://localhost:3000/articles/next-js-app-dir-tutorial にアクセスして、どちらのページも同じレイアウトが適用されていることを確認してみましょう。

/ のパスにレイアウトが提供されている
articles/next-js-app-dir-tutorial のパスにレイアウトが提供されている

Chakra UI を導入する

ここまで作成したアプリケーションは少し味気ないので、スタイリングのために Chakra UI を導入します。Chakra UI は React で使える UI ライブラリです。UI コンポーネントが多く提供されているうえ、カスタマイズ性に優れているのが特徴です。

まずは Chakra UI をインストールします。

npm i @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^6

Provider を設定する

Chakra UI を使うためには ChakraProvider をアプリケーションのルートに設定する必要があります。app ディレクトリにおいては app/layout.tsx がルート要素になります。ここに ChakraProvider を設定してみましょう。

app/layout.tsx
  import Link from "next/link";
+ import { ChakraProvider } from "@chakra-ui/react";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
+         <ChakraProvider>
            <header>
              {/* ... */}
+         </ChakraProvider>
        </body>
      </html>
    );
  }

しかし、このままではコンパイルエラーが発生します。

Failed to compileと表示され、エラーメッセージが表示されている

これは app ディレクトリ内のコンポーネントがデフォルトで React Server Component として扱われることが原因です。

Server Component と Client Component を使い分ける

Server Component はコンポーネントをサーバーサイドでのみレンダリングする仕組みです。Server Component は以下のようなメリットが存在します。

  • クライアントに JavaScript を送信しない。
  • データベースや GraphQL エンドポイントへのアクセスをより近い場所で行うことができる。

そのため最初のページの読み込みが早くなり、クライアント側の JavaScript バンドルサイズも小さくなるといった恩恵を受けることができます。基本的にはデフォルトの Server Component のまま利用するべきです。

しかし、Server Component にはいくつかの制限があります。

  • 状態を持たないので useState のようなフックや Context は使えない
  • useEffect のようなライフサイクルフックは使えない
  • localStorage のようなブラウザのみ利用可能な API は使えない
  • onClickonChange のようなイベントハンドラーは使えない

useState を利用して状態管理をする、イベントハンドラを利用してインタラクティブなアクションを行うコンポーネントは Client Component として扱う必要があります。app ディレクトリ内のコンポーネントを Client Component として扱うにはファイルの先頭で "use client" を宣言します。

このように Server Component と Client Compoennt は互いに長所と短所を補い合っているため、適切に使い分ける必要があります。

<ChakraProvider> は内部で useState を使用しているので Server Component として扱うことができません。これがコンパイルエラーが発生した原因です。

この問題を解決するために、<ChakraProvider> を Server Component として扱うのではなく、Client Component として扱うように設定する必要があります。

<ChakraProvider> のようなサードパーティのコンポーネントを Client Component として扱うためには、"use client" を宣言したファイルでラップします。app/Provider.tsx ファイルを作成しましょう。

app/Provider.tsx
"use client";

import { ChakraProvider } from "@chakra-ui/react";

export default function Provider({ children }: { children: React.ReactNode }) {
  return <ChakraProvider>{children}</ChakraProvider>;
}

そして app/layout.tsx<ChakraProvider><Provider> に置き換えます。

app/layout.tsx
  import Link from "next/link";
- import { ChakraProvider } from "@chakra-ui/react";
+ import Provider from "./Provider";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
-         <ChakraProvider>
+         <Provider>
            <header>
              {/* ... */}
-         </ChakraProvider>
+         </Provider>
        </body>
      </html>
    );
  }

これでコンパイルエラーが解消され、Next.js アプリケーションが正常に起動するようになりました。

Chakra UI のコンポーネントはすべて <ChakraProvider> に依存しているため、Client Component でのみ動作します。そのため UI コンポーネントを使用する場合にはラップして "use client" を宣言する必要があります。

Chakra UI のコンポーネントを使うたびに "use client" を宣言するのは手間がかかります。app/common/components/index.tsx でまとめて Chatra UI のコンポーネントを export して Cliemt Component として使えるようにします。

app/common/components/index.tsx
"use client";
export * from "@chakra-ui/react";

これで Chakra UI のコンポーネントを使いたい場合には

import { Button } from "./common/components";`

のように書くことで "use client" を意識する必要がなくなりました。

ヘッダーコンポーネントの作成

それでは Chakra UI を利用して共通のヘッダーコンポーネントを作りましょう。app ディレクトリでは従来の page ディレクトリと異なり、page.tsx のような特殊なファイル名を使わない限り自由にファイルを配置できます。layout.tsx ファイルの近くにヘッダコンポーネントを配置したいので、app ディレクトリ配下に Header.tsx を作成します。

app/Header.tsx
import { Box, Flex, Heading, Button } from "./common/components";
import NextLink from "next/link";

export default function Header() {
  return (
    <Box as="header">
      <Flex
        bg="white"
        color="gray.600"
        minH={"60px"}
        py={{ base: 2 }}
        px={{ base: 4 }}
        borderBottom={1}
        borderStyle="solid"
        borderColor="gray.200"
        align="center"
      >
        <Flex flex={1} justify="space-between" maxW="5xl" mx="auto">
          <Heading as="h1" size="lg">
            <NextLink href="/">Blog App</NextLink>
          </Heading>
          <Button
            as={NextLink}
            fontSize="sm"
            fontWeight={600}
            color="white"
            bg="orange.400"
            href="/articles/new"
            _hover={{
              bg: "orange.300",
            }}
          >
            記事を書く
          </Button>
        </Flex>
      </Flex>
    </Box>
  );
}

同様に、app/Main.tsxapp/Footer.tsx も作成します。

app/Main.tsx
import { Container } from "./common/components";

export default function Main({ children }: { children: React.ReactNode }) {
  return (
    <Container
      as="main"
      maxW="container.lg"
      my="4"
      minH="calc(100vh - 115px - 2rem)"
    >
      {children}
    </Container>
  );
}
app/Footer.tsx
import { Container, Box, Text } from "./common/components";

export default function Footer() {
  return (
    <Box bg="gray.50" color="gray.700" as="footer">
      <Container maxW="5xl" py={4}>
        <Text as="small">© 2023 azukiazusa</Text>
      </Container>
    </Box>
  );
}

そして app/layout.tsx<Header><Main><Footer> を配置します。

app/layout.tsx
  import Link from "next/link";
  import Provider from "./Provider";
+ import Header from "./Header";
+ import Main from "./Main";
+ import Footer from "./Footer";

  export default function RootLayout({
    children,
  }: {
    children: React.ReactNode;
  }) {
    return (
      <html lang="ja">
        <head />
        <body>
          <Provider>  
+           <Header />
+           <Main>{children}</Main>
+           <Footer />
          </Provider>
        </body>
      </html>
    );
  }

これで基本的なレイアウトが完成しました。期待どおりに表示されていることを確認してみましょう。

Chakra UI を適用した後のレイアウト

記事の一覧を表示する

API から記事の一覧を取得してトップページに表示してみましょう。API はあらかじめ pages/api/ ディレクトリに用意されています。

Next.js でデータフェッチングを行うにはサーバーサイドで行うことが一般的です。しかし、従来の Next.js で提供されていた getServerSidePropsgetStaticPorpsapp ディレクトリではサポートされていません。その代わりに API からのデータを取得は、Server Component 内で async/await を使って行います。Server Component では非同期コンポーネントとしても動作します。

app ディレクトリによるデータフェッチングは基本的に Fetch API を使用します。Fetch API は Web API にネイティブで備わっている機能ですが、Next.js で使う際には次のように拡張されています。

  • 自動的にリクエストの重複排除する
  • デフォルトで動的関数(cookies(), headers(), useSearchParams())の前に呼ばれるリクエストが HTTP キャッシュされる
  • 独自のキャッシュ戦略として revalidate をサポートする

Client Compoennt でもデータフェッチングを行うことができますが、以下の理由から常に Server Component 内で行うことを推奨されています。

  • データベースなどバックエンドのリソースに直接アクセスできる
  • アクセストークンなどの機密情報をクライアントに露出しない
  • データの取得とレンダリングを同一環境下で行うのでクライアントとサーバーの通信と、クライアント上のメインスレッドの作業を削減できる
  • 複数のデータフェッチングを 1 つのリクエストで行うことができる
  • データソースにより近い場所でデータを取得することで、レイテンシを削減できる

それでは実際に Server Component でデータフェッチングを行ってみましょう。先に型定義を用意しておきます。app/types.ts ファイルを作成します。

app/types.ts
export type Article = {
  id: number;
  title: string;
  content: string;
  slug: string;
  createdAt: string;
  updatedAt: string;
};

export type Comment = {
  id: number;
  body: string;
  articleId: number;
  createdAt: string;
  updatedAt: string;
  author: Author;
};

export type Author = {
  name: string;
  avatarUrl: string;
};

app/pages.tsx 内で http://localhost:3000/api/articles から記事の一覧を取得します。

app/pages.tsx
import type { Article } from "./types";

async function getArticles() {
  const res = await fetch("http://localhost:3000/api/articles");

  // エラーハンドリングを行うことが推奨されている
  if (!res.ok) {
    throw new Error("Failed to fetch articles");
  }

  const data = await res.json();
  return data.articles as Article[];
}

export default async function Home() {
  const articles = await getArticles();

  return (
    <div>
      <h1>新着記事</h1>
      <ul>
        {articles.map((article) => (
          <li key={article.id}>{article.title}</li>
        ))}
      </ul>
    </div>
  );
}

getArticles 内の処理は一般的な fetch の使い方と同じです。コンポーネント内で例外を投げた場合には後述する error.tsx によりエラー画面が表示されます。

Home コンポーネントで async/await を使用することにより自然な流れでデータを取得して使用できます。

データのキャッシュ

データフェッチングのキャッシュについても考えてみましょう。デフォルトでは fetch を使用すると自動的にデータを取得した後にキャッシュされます。これは fetch のオプションはデフォルトで { cache: "force-cache" } が設定されていることを意味します。force-cache オプションは getStaticProp と近い働きです。

ここでは新着の記事の一覧を取得しているので、データの更新が頻繁に行われる可能性があります。そのため、データのキャシュは行わないようにして、毎回リクエストを行うように設定します。

fetch を実行するたびに新しいデータを取得するようにするには、cache: "no-store" を設定します。no-storegetServerSideProp と近い働きです。

app/pages.tsx
const res = await fetch("http://localhost:3000/api/articles", {
  cache: "no-store",
});

ローディング UI

新着記事一覧の取得には 1500 ミリ秒かかるようにディレイを設定しています。その間データの取得と関係のないヘッダー部分も含めて何も表示されないのは、ユーザーフレンドリーではありません。そこで、データの取得中にローディング UI を表示するようにしてみましょう。

Next.js 13 では app ディレクトリ内の loading.tsx という特殊なファイルがローディング UI を表示する役割を果たします。loading.tsx はサーバーでデータを取得している最中(= サーバーコンポーネントの Promise が解決するまで)に表示され、レンダリングが完了すると新しいコンテンツを表示します。この挙動は Suspense における fallback と同じです。

イメージとしては以下のような感じです。

<html lang="ja">
  <head />
  <body>
    <Provider>
      <Header />
      <Main>
        <Suspense fallback={<Loading />}>
          {/* children には page.tsx のコンテンツが挿入される */}
          {children}
        </Suspense>
      </Main>
      <Footer />
    </Provider>
  </body>
</html>

app/loading.tsx を次のように作成します。

app/loading.tsx
import { Box, Spinner } from "./common/components";

export default function Loading() {
  return (
    <Box justifyContent="center" display="flex">
      <Spinner color="orange.400" size="xl" />
    </Box>
  );
}

loading.tsx により、記事の一覧を取得するまでローディング UI が表示されるようになりました。loading.tsx は同じディレクトリ内の page.tsx をラップするように配置するので、ヘッダーなどのレイアウトは即座に表示されます。

ローディング UI が表示されている

エラーハンドリング

サーバーコンポーネント内で例外が throw された場合、error.tsx の内容が表示されます。error.tsx は同じディレクトリ内にある page.tsx ファイルを Error Boundary でラップします。

error.tsx ファイルは次のように動作するイメージです。

<html lang="ja">
  <head />
  <body>
    <Provider>
      <Header />
      <Main>
        <ErrorBoundary fallback={<Error />}>
          {/* children には page.tsx のコンテンツが挿入される */}
          {children}
        </Suspense>
      </Main>
      <Footer />
    </Provider>
  </body>
</html>

Error コンポーネントは以下の Props を受け取ります。

  • error:throw された例外オブジェクト
  • reset:例外が発生したコンポーネントを再レンダリングするための関数

また error.tsx は必ず Client Component として扱われます。

app/error.tsx
"use client"; // Error components must be Client components

import { useEffect } from "react";
import { Heading, Button } from "./common/components";

export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  useEffect(() => {
    console.error(error);
  }, [error]);

  return (
    <div>
      <Heading mb={4}>予期せぬエラーが発生しました。</Heading>
      <Button onClick={() => reset()}>Try again</Button>
    </div>
  );
}

app.page.tsx で意図的に例外を発生させることで、エラーハンドリングの動作を確認してみましょう。

app/page.tsx
  async function getArticles() {
    const res = await fetch("http://localhost:3000/api/articles", {
      cache: "no-store",
    });

+   throw new Error("Failed to fetch articles");

エラーが発生した場合の UI

ArticleList コンポーネント

最後に記事の表示を担当するコンポーネントを作成して見た目を整えましょう。まずは ArticleCard コンポーネントを作成します。

app/components/ArticleCard.tsx
import {
  Card,
  CardHeader,
  CardBody,
  CardFooter,
  Heading,
  Text,
} from "./common/components";
import NextLink from "next/link";
import { Article } from "./types";

export default function ArticleCard({ article }: { article: Article }) {
  const formattedDate = new Date(article.createdAt).toLocaleDateString(
    "ja-JP",
    {
      year: "numeric",
      month: "long",
      day: "numeric",
    }
  );
  return (
    <Card
      as={"li"}
      _hover={{
        boxShadow: "xl",
      }}
      minW="100%"
    >
      <NextLink href={`/articles/${article.slug}`}>
        <CardHeader>
          <Heading size="md">{article.title}</Heading>
        </CardHeader>
        <CardBody>
          <Text>{article.content.substring(0, 200)}...</Text>
        </CardBody>
        <CardFooter>
          <Text fontSize="sm" color="gray.600">
            {formattedDate}
          </Text>
        </CardFooter>
      </NextLink>
    </Card>
  );
}

記事のカードを一覧で表示する ArticleList コンポーネントを作成します。

app/components/ArticleList.tsx
import { VStack } from "./common/components";
import ArticleCard from "./ArticleCard";
import { Article } from "./types";

export default function ArticleList({ articles }: { articles: Article[] }) {
  return (
    <VStack spacing={4} as="ul">
      {articles.map((article) => (
        <ArticleCard key={article.id} article={article} />
      ))}
    </VStack>
  );
}

ArticleList コンポーネントを app/page.tsx に組み込みます。

app/page.tsx
import ArticleList from "./ArticleList";
import { Heading } from "./common/components";

// ...

export default async function Home() {
  const articles = await getArticles();

  return (
    <div>
      <Heading as="h1" mb={4}>
        新着記事
      </Heading>
      <ArticleList articles={articles} />
    </div>
  );
}

http://localhost:3000 にアクセスすると、次のように記事の一覧が表示されるはずです。

ArticleList コンポーネントにより描画された記事一覧ページ

記事の詳細ページ

記事の詳細ページを作成します。ここでは記事の本文を取得して表示するとともに、記事に対するコメントを表示する機能を実装します。特定の記事は api/articles/{slug} から、記事に対するコメントは api/articles/{slug}/comments から取得します。

それぞれのキャッシュ戦略も考えてみましょう。記事の本文は、更新頻度が高くないと想定できるので、一定期間キャッシュしておくことができます。キャッシュの生存期間は fetch のオプションに next.revalidate を指定することで設定できます。これは従来の ISR に近い機能です。

一方で、コメントは投稿した後即座に反映されないと不自然ですので、キャッシュを利用しないほうが良いでしょう。記事の一覧取得と同様に、fetch のオプションに cache: "no-store" を指定します。

app/pages/articles/[slug].tsx
import { notFound } from "next/navigation";
import { Article, Comment } from "../../types";

const getArticle = async (slug: string) => {
  const res = await fetch(`http://localhost:3000/api/articles/${slug}`, {
    next: { revalidate: 60 },
  });

  if (res.status === 404) {
    // notFound 関数を呼び出すと not-fount.tsx を表示する
    notFound();
  }

  if (!res.ok) {
    throw new Error("Failed to fetch article");
  }

  const data = await res.json();
  return data as Article;
};

const getComments = async (slug: string) => {
  const res = await fetch(
    `http://localhost:3000/api/articles/${slug}/comments`,
    {
      cache: "no-store",
    }
  );

  if (!res.ok) {
    throw new Error("Failed to fetch comments");
  }

  const data = await res.json();
  return data.comments as Comment[];
};

記事を取得する際に API が 404 を返した場合は next/navigationnotFoutd 関数を呼び出しています。この関数が呼ばれるともっとも近いディレクトリにある not-found.tsx が表示されます。

not-found.tsx も作成しておきましょう。

app/pages/articles/not-found.tsx
import { Heading, Button } from "../../common/components";
import NextLink from "next/link";

export default function NotFound() {
  return (
    <div>
      <Heading mb={4}>お探しの記事が見つかりませんでした。</Heading>
      <Button as={NextLink} href="/">
        トップへ戻る
      </Button>
    </div>
  );

存在しない記事の URL にアクセスすると、以下のように表示されます。

存在しない記事の URL にアクセスしたときの表示

記事の詳細の表示に戻りましょう。コンポーネント内で getArticlegetComments を呼び出して、取得したデータを表示します。依存関係のない複数の API を呼び出す場合は処理が並列になるように Promise.all を使うことが推奨されます。

app/pages/articles/[slug].tsx
export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentsPromise = getComments(params.slug);

  const [article, comments] = await Promise.all([
    articlePromise,
    commentsPromise,
  ]);

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
      <h2>Comments</h2>
      <ul>
        {comments.map((comment) => (
          <li key={comment.id}>{comment.body}</li>
        ))}
      </ul>
    </div>
  );
}

これで記事詳細ページを訪れると、記事の内容とコメントの一覧が表示されるようになりました。しかし 1 点問題があります。記事の表示に時間がかかりすぎる点です。

記事の取得には 1000 ミリ秒、コメントの一覧には 3000 ミリ秒の遅延を設定しています。記事の本文を閲覧するだけであれば本来は 1000 ミリ秒で表示できるはずです。しかし、Promise.all でコメント一覧の取得の完了も同時に待機しているため、記事の内容の表示に 3000 ミリ秒かかってしまっています。

記事の詳細を表示するまでローディングインジケータが表示されている

ユーザーが記事詳細ページを訪れる目的は記事の本文を閲覧することであり、コメントの一覧はあくまで補足情報です。そのため、コメントの一覧の取得の完了まで待つのは好ましくありません。

そこでコメント一覧の取得にストリーミングを使ってみましょう。ストリーミングを使うとコメントの一覧の取得が完了するまで待つことなく、記事の取得が完了したタイミングで記事の本文を表示できます。

コメントをストリーミングで取得する

ストリーミングはページの HTML を小さなチャンクに分解してクライアントに漸進的に送信します。これにより、すべてのデータの取得が完了するまで待つことなく、ページの一部分から表示を開始できます。

ストリーミングを行う箇所を制御するためには <Suspense> で非同期コンポーネントをラップします。次のようにコメント一覧を取得する箇所を別のコンポーネントに分割し、<Suspense> でラップします。

app/pages/articles/[slug].tsx
export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentPromise = getComments(params.slug);

  const article = await articlePromise;

  return (
    <div>
      <h1>{article.title}</h1>
      <p>{article.content}</p>
      <h2>Comments</h2>
      <Suspense fallback={<div>Loading comments...</div>}>
        {/* @ts-expect-error 現状は jsx が Promise を返すと TypeScript が型エラーを報告するが、将来的には解決される */}
        <Comments commentPromise={commentPromise} />
      </Suspense>
    </div>
  );
}

async function Comments({
  commentPromise,
}: {
  commentPromise: Promise<Comment[]>;
}) {
  const comments = await commentPromise;
  return (
    <ul>
      {comments.map((comment) => (
        <li key={comment.id}>{comment.content}</li>
      ))}
    </ul>
  );
}

それでは動作を確認してみましょう。1000 ミリ秒ほど経過した後に記事の本文が表示され、その間コメント一覧には Loading comments と表示されています。その後さらに 2000 ミリ秒経過した後にコメントの一覧が表示されます。

コメント一覧をストリーミングで取得している

<head> タグ

記事の詳細ページでは SEO のためにも記事のタイトルを <title> タグを設定しておきたいものです。ルーティングごとに <head> タグを設定するには、head.tsx という特殊なファイルを配置します。head.tsx ファイルはルートレイアウトの <head /> タグ内に挿入されます。

Head コンポーネント内では以下のタグを使用できます。

  • <title>
  • <meta>
  • <link>
  • <script>

ヘッダコンポーネントは Server Component と同様に async/await を使って動的に値を取得して設定できます。

app/pages/articles/[slug]/head.tsx
import { Article } from "../../types";

const getArticle = async (slug: string) => {
  const res = await fetch(`http://localhost:3000/api/articles/${slug}`, {
    next: { revalidate: 60 },
  });

  if (!res.ok) {
    // page.tsx と異なり例外を投げても erorr.tsx に捕捉されない
    return null;
  }

  const data = await res.json();
  return data as Article;
};

export default async function Head({ params }: { params: { slug: string } }) {
  const article = await getArticle(params.slug);
  return (
    <>
      <title>{article?.title}</title>
      <meta name="description" content={article?.content} />
    </>
  );
}

app/pages/articls/[slug]/page.tsx と同じリクエストを送信しているので一見非効率なように思えますが、Next.js により fetch を利用したリクエストは自動で重複排除されるのでパフォーマンスには影響しません。

Head コンポーネントを使用する際に、上位のディレクトリの head.tsx ファイルの内容を自動的に引き継がないことに注意してください。

例えば app/head.tsx では以下のように記述されています。

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

ファビコンや viewport の設定はすべてのページで共通なので、下位のディレクトリで設定しなくても引き継いでほしいのです。しかし実際には、一番近い箇所にある head.tsx ファイルですべての内容が上書きされるようになっています。

そのため記事詳細ページでは <title><meta name="description"> のみが存在することになってしまいます。

このような場合には共通のコンポーネント作成する方法が紹介されています。まずは app/DefaultTags ファイルを作成します。ここには全ページで共通の <head> を設定します。

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

そして、各ページの head.tsx では DefaultTags をインポートして <head> に追加します。

app/pages/articles/[slug]/head.tsx
+ import DefaultTags from "../../DefaultTags";

  export default async function Head({ params }: { params: { slug: string } }) {
    const article = await getArticle(params.slug);
    return (
      <>
        <title>{article.title}</title>
        <meta name="description" content={article.content} />
+      <DefaultTags />
      </>
    );
  }

スタイリング

最後に Chakra UI によるスタイリングを適用します。以下のコンポーネントを作成します。

  • app/articles/[slug]/ArticleContent.tsx:記事のタイトルと本文を表示するコンポーネント
  • app/articles/[slug]/Comments.tsx:コメントの一覧を表示するコンポーネント
  • app/articles/[slug]/LoadingComments.tsx:コメントの読み込み中に表示されるコンポーネント
app/articles/[slug]/ArticleContent.tsx
import {
  Card,
  CardHeader,
  CardBody,
  Text,
  Heading,
} from "../../common/components";
import { Article } from "../../types";

export default function ArticleContent({ article }: { article: Article }) {
  return (
    <Card as="article">
      <CardHeader>
        <Heading as="h1">{article.title}</Heading>
      </CardHeader>
      <CardBody>
        <Text as="p" fontSize="md">
          {article.content}
        </Text>
      </CardBody>
    </Card>
  );
}
app/articles/[slug]/Comments.tsx
import {
  Card,
  CardBody,
  StackDivider,
  VStack,
  Text,
  Box,
  Avatar,
  Flex,
} from "../../common/components";
import { Comment } from "../../types";

export default async function Comments({
  commentPromise,
}: {
  commentPromise: Promise<Comment[]>;
}) {
  const comments = await commentPromise;

  if (comments.length === 0) {
    return (
      <Text as="p" fontSize="md">
        コメントはありません。
      </Text>
    );
  }
  return (
    <VStack
      divider={<StackDivider borderColor="gray.200" />}
      spacing={4}
      as="ul"
      align="stretch"
      px={4}
    >
      {comments.map((comment) => (
        <CommentItem key={comment.id} comment={comment} />
      ))}
    </VStack>
  );
}

function CommentItem({ comment }: { comment: Comment }) {
  return (
    <Flex as="li" listStyleType="none" align="center">
      <Avatar
        size="sm"
        name={comment.author.name}
        src={comment.author.avatarUrl}
        mr={4}
      />
      <Text fontSize="sm">{comment.body}</Text>
    </Flex>
  );
}
app/articles/[slug]/LoadingComments.tsx
import {
  StackDivider,
  VStack,
  Flex,
  SkeletonCircle,
  Skeleton,
} from "../../common/components";

export default function LoadingComments({}) {
  return (
    <VStack
      divider={<StackDivider borderColor="gray.200" />}
      spacing={4}
      as="ul"
      align="stretch"
      px={4}
    >
      <CommentSkeltonItem />
      <CommentSkeltonItem />
      <CommentSkeltonItem />
    </VStack>
  );
}

function CommentSkeltonItem() {
  return (
    <Flex as="li" listStyleType="none" align="center">
      <SkeletonCircle size="8" mr={4} />
      <Skeleton height="14px" width="60%" />
    </Flex>
  );
}

そして、app/articles/[slug]/index.tsx でこれらのコンポーネントを使用します。

app/articles/[slug]/index.tsx
import ArticleContent from "./ArticleContent";
import Comments from "./Comments";
import { Heading } from "../../common/components";
import LoadingComments from "./LoadingComments";

const getArticle = async (slug: string) => {
  // ...
}

const getComments = async (slug: string) => {
  // ...
}

export default async function ArticleDetail({
  params,
}: {
  params: { slug: string };
}) {
  const articlePromise = getArticle(params.slug);
  const commentPromise = getComments(params.slug);

  const article = await articlePromise;

  return (
    <div>
      <ArticleContent article={article} />
      <Heading as="h2" mt={8} mb={4}>
        Comments
      </Heading>
      <Suspense fallback={<LoadingComments />}>
        {/* @ts-expect-error 現状は jsx が Promise を返すと TypeScript が型エラーを報告するが、将来的には解決される */}
        <Comments commentPromise={commentPromise} />
      </Suspense>
    </div>
  );
}

最終的には以下のような表示になります。

Chakra UI によってスタイリングされた記事詳細

記事の作成

次に記事の作成機能を実装します。/articles/new というパスで記事の作成フォームを表示したいので、app/articles/new/page.tsx というファイルを作成します。フォームによる状態管理を行うので、Client Component として実装します。

app/articles/new/index.tsx
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";
import {
  Heading,
  FormControl,
  FormLabel,
  Input,
  Textarea,
  Button,
} from "../../common/components";

export default function CreateArticle() {
  const router = useRouter();
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [loading, setLoading] = useState(false);

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setLoading(true);
    await fetch("/api/articles", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ title, content }),
    });
    setLoading(false);
    router.push("/");
  };

  return (
    <div>
      <Heading mb={4}>Create Article</Heading>

      <form onSubmit={handleSubmit}>
        <FormControl>
          <FormLabel>タイトル</FormLabel>
          <Input value={title} onChange={(e) => setTitle(e.target.value)} />

          <FormLabel>本文</FormLabel>
          <Textarea
            value={content}
            onChange={(e) => setContent(e.target.value)}
          />
          <Button
            type="submit"
            color="white"
            bg="orange.400"
            isLoading={loading || isPending}
            mt={4}
          >
            作成
          </Button>
        </FormControl>
      </form>
    </div>
  );
}

記事のタイトルと本文を入力するフォームを表示し、作成ボタンを押すと fetch で記事作成 API を呼び出します。API のコールが完了したら router.push("/") によりトップページに遷移します。

app ディレクトリ内で useRouter を使うためには next/router ではなく next/navigation をインポートする必要があります。

以下のように表示され、記事の作成もできるようになりました。

Chakra UI によってスタイリングされた記事作成フォーム

しかし 1 つ問題があります。記事の作成後、トップページに遷移したときに作成した記事が表示されていません。

記事を投稿した後、記事の一覧画面に遷移すると投稿した記事が表示されていない

これは router.push による画面遷移が Soft Navigation であるためです。Soft Navigation では遷移先のキャッシュが存在する場合再利用され、サーバーへの新たなリクエストが発生しません。

トップページのキャッシュが存在するため、古い記事一覧が表示されてしまっています。キャッシュを無効にするためには router.refresh を呼び出す必要があります。

app/articles/new/index.tsx
- import { useState } from "react";
+ import { useState, useTransition } from "react";

  // ...

+   const [isPending, startTransition] = useTransition();

    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault();
      setLoading(true);
      await fetch("/api/articles", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ title, content }),
      });
      setLoading(false);
      router.push("/");
+     startTransition(() => {
+       router.refresh();
+     });
    };

router.refresh を呼び出しキャッシュを無効にしたことにより、トップページに遷移した際には新たにサーバーからデータを取得するようになりました。

記事を投稿した後、記事の一覧画面に遷移すると投稿した記事が表示されている

まとめ

app ディレクトリの基本的な動作について確認してきました。Server Component がデフォルトとなっているのが大きな特徴で、よりパフォーマンスの高いアプリケーションを作成できるのは嬉しい変更点だと思えます。

また app ディレクトリではページに関連するファイルを 1 つのディレクトリ内にまとめることができるのもポイントの 1 つです。従来のファイル構成と異なるアプローチも考えられるのではないでしょうか。

参考

GitHubで編集を提案

Discussion

ログインするとコメントできます