✏️

Next.js 13のappディレクトリを用いたアプリケーションのサンプル実装

2023/01/13に公開

Next.js 13がリリースされて約2カ月、現在のバージョンは13.1.1でappディレクトリ周りのTypeScriptのサポートもぼちぼち対応が入ってきたのでサンプルアプリケーションを実装しました。

AKIRA-MIYAKE/next13-experimental

titlebody を持つ Post リソースのREST APIを提供するサーバがあり、その一覧と詳細を表示、ログイン状態であれば、APIを用いて Post の作成、変更、削除を行うことができるアプリケーションです。
ログイン可能なユーザは固定で、データもAPIサーバのオンメモリに保持する簡易なものですが、Next.js側の実装はそれなりに実践的なものとなっています。

Next.js 13に追加された機能の解説などはすでに多く存在するため、実践的なアプリケーションを実装する際にキーになると予想されるポイントについて説明していきます。

キーとなるポイント

Server Component

appディレクトリに配置されたコンポーネントは、デフォルトでServer Componentとして扱われます。(参考)

Server Componentのその名の通りサーバサイドで実行されるコンポーネントですが、実装する際の特徴が2つあります。

1つ目は onClick などのイベントハンドラを追加することができず、状態を持つことができない( useState などを利用できない)という制約があること。
2つ目は非同期処理を扱う( Promise<JSX.Element> を返す)ことができ、外部のAPIやバックエンドのリソースからデータを取得することができるということ。

1つ目の特徴は、propsとして渡された値を表示するだけの非対話型コンポーネントだけを実装できることを意味しますが、Client Componentを利用することで従来通りのインタラクティブなコンポーネントをブラウザ上で実行することができるため、そこまで大きな問題にはならないでしょう。
コードとしてもハンドラやフックを含まない通常の関数コンポーネントであり、appディレクトリ上のコンポーネントを従来のpagesディレクトリのコンポーネントにインポートして利用することも可能です。
ビルド時に生成されるhtmlにはClient Componentが返す結果も含まれるため、SEO上のデメリットもありません。

2つ目の特徴はこれまでのReactやNext.jsからは少し特異に見えるでしょう。
正確には React.lazy を利用したコンポーネントの遅延読み込みと類似しています。

React.lazyReact.Suspense を用いるコンポーネントの遅延読み込みの例(参考)

// This component is loaded dynamically
const OtherComponent = React.lazy(() => import('./OtherComponent'));

function MyComponent() {
  return (
    // Displays <Spinner> until OtherComponent loads
    <React.Suspense fallback={<Spinner />}>
      <div>
        <OtherComponent />
      </div>
    </React.Suspense>
  );
}

内部でデータを取得する( Promise<JSX.Element> を返す)Server Componentを利用する例(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/app/posts/page.tsx#L9-L32)

const Page: () => Promise<JSX.Element> = async () => {
  return (
    <>
      <main>
        <Container>
          <div className="space-y-6 mb-6">
            <ClientAuthorToolbar />

            <div>
              <h1 className="text-4xl font-bold">Posts</h1>
            </div>

            <Suspense fallback={<div>Loading...</div>}>
              {/* @ts-expect-error Server Component */}
              <ServerPostList />
            </Suspense>
          </div>
        </Container>
      </main>

      <ClientRouterRefreshIfNeeded />
    </>
  )
}

非同期のServer Componentを利用する場合、Promiseが解決されるまでは最も近いSuspenseのfallbackが表示され、解決されたタイミングでコンポーネントが返す結果が表示される、という理解で問題ないでしょう。
静的レンダリングの場合は、Promiseが解決された後の結果がhtmlに出力されます。
なお、非同期のServer Componentの利用に際してTypeScriptの型エラーが発生するでしょう。 {/* @ts-expect-error Server Component */} を用いることでその型チェックを無効にすることができます。(参考)

非同期のServer Component自体の実装は以下のようになります。(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/app/posts/components/ServerPostList/index.tsx)

import { PostList } from '../../../../components/posts/PostList'

import { listPost } from '../../data'

export const ServerPostList: () => Promise<JSX.Element> = async () => {
  const postCollection = await listPost()

  return <PostList posts={postCollection.items} />
}

listPostPost のコレクションを返す非同期関数です。基本的にはasync/awaitを利用できる関数コンポーネントと考えることができます。

Data Fetching

先ほどの listPost の実装は次のようになります。(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/app/posts/data.ts)

import { Post } from '../../interfaces'

export const listPost: () => Promise<{ items: Post[] }> = async () => {
  const response = await fetch('http://localhost:3001/posts')

  if (!response.ok) {
    const body = await response.json()
    throw new Error(body.message)
  }

  return response.json()
}

REST APIからデータを取得するなんの変哲もないコードに見えますが、このコードはサーバサイドでのみ実行されます。Node.js 18から実装された fetch を使っているように見えますが、v16.8から動作します。(参考)
これは、Next.jsが独自に拡張する fetch 関数を利用するためです。(参考)
この fetch は外部のAPIからデータ取得を行いながら、データのキャッシュ、静的リソースの再生成のタイミングの制御のために参照されます。(参考)

デフォルトでは静的レンダリングが優先されるようになっており、静的ルーティングで、データ取得の際に cookie()header() といったクライアントのリクエストに応じて動的に取得データを変更する可能性のある関数を利用していない場合、ビルド時に静的なhtmlもしくはデータを含めたレンダリングの結果が生成されます。
サンプルアプリケーションでは、 /posts ページがデフォルトの状態では静的レンダリングの対象となります。
静的レンダリングのページは、アプリケーションの起動後にデータが変更されたとしても再生成されません。
Post を新たに作成したり、既存のものを削除したとしても、 /posts に表示される一覧はビルド時のままということとなります。

これを解決するには、主に2つの方向性があります。

1つ目は動的レンダリングの採用。 fetch もしくはセグメントでキャッシュを無効にすることを宣言することで、アクセス毎にデータ取得を行うようにすることができます。(参考)

2つ目は定期的な静的リソースの再生成、もしくはオンデマンの再生成。 fetch またはセグメント毎に、再生成のインターバルを設定することができます。(参考)
オンデマンドの再生成はいわゆるIncremental Static Regeneration (ISR)で、任意のタイミングで特定のURLの静的リソースを生成、もしくは再生成することができます。(参考)

今回のサンプルアプリケーションでは、1つ目の動的レンダリングの方法を採用しました。
アプリケーション内でリソースの作成、変更、削除の機能を備えており、変更された結果を直ちに画面に反映させたかったためです。
ただ、アクセス毎のデータの取得といっても、基本的にはクライアントサイドで起動した後に初めて取得したデータが2度目以降も表示されるようです。下位のセグメントから Link で移動した場合は取得が行われているように見えるなど、再取得が行われるパターンもあるようですが、 /posts/create で作成後に /postsrouter.push() で遷移、 /posts/edit で削除後に /postsrouter.push() で遷移といったパターンでは、再取得は行われませんでした。
/posts ページに対して、 refresh=true のクエリストリングが存在する場合は router.refresh() を実行するという処理を追加することで、明示的にデータの再取得を行なっています。(参考)
router.refresh() は実行されたページのデータの再取得が行われます。そのため、 /posts/createposts/[postId]/editrouter.refresh() を実行したとしても、 /posts のデータの再取得が行われるわけではないため、前述のような実装としています。

2つ目のオンデマンドの再生成も試してみたのですが、静的コンテンツであるため router.refresh() でデータの再取得ができず、新たに生成されたデータを表示するにはブラウザのリロードしか方法がなかったため採用しませんでした。

コンテキストの利用とpagesディレクトリとの併用

サーバサイドではコンテキストを利用することはできませんが、Client Componentとしてクライアントサイドでは従来通りコンテキストを利用することができます。(参考)

サンプルアプリケーションでは Root Layout で認証情報を保持する AuthContext のプロバイダーをレンダリングするようにしています。(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/app/layout.tsx)

import type { ReactNode } from 'react'

import { AuthContextProvider } from '../contexts/AuthContext'

import '../styles/global.css'

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

export default RootLayout

Root Layout はアプリケーションのルートに常に存在し続けるため、クライアントサイドでコンテキストのレンダリングが行われた後は、どこからでも利用することが可能となります。

さて、従来のpagesディレクトリでは次のようにコンテキストのプロバイダを設定します。(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/pages/_app.tsx)

import type { AppProps } from 'next/app'
import Head from 'next/head'

import { AuthContextProvider } from '../contexts/AuthContext'

import '../styles/global.css'

const App: (props: AppProps) => JSX.Element = ({ Component, pageProps }) => {
  return (
    <>
      <Head>
        <title>Next13 Experimental</title>
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <meta name="description" content="Next13 experimental app" />
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <AuthContextProvider>
        <Component {...pageProps} />
      </AuthContextProvider>
    </>
  )
}

export default App

appディレクトリのページでは pages/_app.tsx は読み込まれることなく、またpagesディレクトリのページでは app/layout.tsx が読み込まれることはありません。
このことは、appディレクトリとpagesディレクトリのページ間では、コンテキストを用いた状態の共有ができないことを意味します。
また、それぞれのディレクトリのページを行き来する際は、別のSPAが起動するような挙動となりコンテキスのプロバイダも初期状態に戻ります。
appディレクトリとpagesディレクトリの両方を利用している状態で、コンテキストを用いて一貫した状態を保持する場合は、その状態をSessionStorageなどに保存し、初期化のたびにそれを取り出して状態を復元するという処理を追加する必要があるでしょう。

その他気になったこと

ディレクトリ構成

appディレクトリはpagesディレクトリと異なり、ルーティングに関係のないファイルやフォルダを配置することができます。(https://beta.nextjs.org/docs/routing/fundamentals#colocation)
特定のページでのみ利用するコンポーネントを近くに配置できるのでは良いと思うのですが、多くのページで横断的に利用されるコンポーネントをappディレクトリに配置するのは、ルーティングを把握する時に煩雑になるなと感じました。
今回のサンプルではpagesディレクトリと共用するコンポーネントもあったため、以下のようなディレクトリ構成としました。

root
  ├ public
  └ src
      ├ app
      ├ components
      ├ context
      ├ interfaces
      ├ pages
      └ styles

src/components にはサーバサイドとクライアントサイドの両方、もしくはクライアントサイドでのみ利用可能なコンポーネントを配置します。
このとき、クライアントサイドでのみ利用可能なコンポーネントには Client のプレフィックスを付与しましたが、そこまで明示する必要はないかもしれません。コンポーネントがpropsでハンドラを要求する場合はServer Componentから直接利用することはできず(関数を渡そうとするとエラーが発生するため)、ハンドラを要求しない場合は問題なく利用することができるためです。

src/app では特定のページで利用するコンポーネントを配置するための components ディレクトリをそれぞれ持つことを許容するようにしており、データ取得を行う非同期関数を src/components のコンポーネントと組み合わせて非同期のSever Componentとするという実装にしています。

Layoutの共有と実装

layout.tsx で定義したLayoutは複数のコンポーネントで共有され、ルーティングに応じてネストされます。(参考)
あるルーティングで定義したLayoutはその下位のルーティングのページ全てで表示されることとなるのですが、例えばLayoutにサイドバーを含めた場合、下位のルーティングのあるページではサイドバーを非表示にしたい、というパターンに対応できません。
URLパスに影響を与えずにルーティングを整理することはできるのですが(参考)、レイアウトのパターンでグルーピングを行うと前述のようなパターンではルーティングの定義が煩雑になります。

サンプルアプリケーションでは、サインインページもしくはアカウントページへのリンクを含む AppHeader を基本的には共有するが、サインインページにはそのリンクをしないという仕様です。
そこで、 src/app/layout.tsx のRootLayoutでは AppHeader を含めず、個別のLayoutもしくはページで必要があれば AppHeader を含めるという実装としました。
Reactのコンポーネントツリーが保たれるのであれば状態は引き継がれるはずですし、必要であればコンテキストを用いて状態を共有すれば良いでしょう。

Storybookの利用

サンプルアプリケーションには加えていませんが、現状では非同期のServer ComponentをStorybookで扱うことはできないでしょう。
細かなコンポーネント単位であれば今回のサンプルの ServerPostList のようにPresentational Componentである PostList に分離し、Storybookでは PostList を対象とするというような形式を取ることができます。
ただ、ページ単位でプレビューを表示するというような利用をしている場合は、おそらく非同期のSever Componentを含まないプレビュー用のページに相当するコンポーネントを別途定義する必要があると思われます。

クライアントのみで動作するフックの追加

(2023-01-15追記)

useEffectuseState などのフックはClient Componentでしか利用することができません。
フォームなどに対する処理の場合、素直に該当するコンポーネントに記述すればいいのですが、例えばURLにあるクエリストリングを含む場合は特定の処理を行うというような処理を、いずれかのUIを表示するためのコンポーネントに記述するのは違和感があります。

サンプルアプリケーションでは、 /posts/posts/[postId] のページで、クエリストリングに refresh=true が含まれる場合は router.refresh() を実行するという処理があります。

実装方式としては、次のようなnullを返すClient Componentを実装して、該当の page.tsx でレンダリングするようにしています。(https://github.com/AKIRA-MIYAKE/next13-experimental/blob/deb63c933d0c0ad0ad954bc1e995aa27b8bb41ae/src/components/commons/ClientRouterRefreshIfNeeded/index.tsx)

'use client'

import type { FC } from 'react'
import { useEffect } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'

export const ClientRouterRefreshIfNeeded: FC = () => {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()

  useEffect(() => {
    if (searchParams.get('refresh') !== 'true') {
      return
    }

    router.refresh()

    const newSearchParams = Array.from(searchParams.entries()).reduce(
      (acc, [key, value]) => {
        if (key === 'refresh') {
          return acc
        }

        return `${acc}&${key}=${value}`
      },
      ''
    )

    const newUrl =
      newSearchParams.length > 0
        ? `${pathname}?${newSearchParams}`
        : pathname || '/'

    router.replace(newUrl)
  }, [router, pathname, searchParams])

  return null
}

個人的にはクライアントのみで動作し、サーバサイドではスキップされるようなカスタムフックを定義することができればと思うのですが、現状ではこのようなClient Componentを実装するしか方法がないようです。

SWRとの連携

(2023-01-15追記)

SWRConfig を用いてServer Componentで取得した値をSWRの初期値として設定することはできそうです。(参考)
SWRConfig の設定はマージされるため、個々のServer Componentで初期値の設定処理を行うことも問題なさそうに見えます。(参考)

ただ、試した限りでは SWRConfig をClient Componentでラップする必要があり、 useSWR を利用するコンポーネントもClient Compomentとする必要がありました。
そして、おそらくSWRの実装方式によりビルド時のレンダリングがスキップされるようで、例えばServer Componentである ServerPostList で取得した listPost の結果をClient Componentとした useSWR を用いるコンポーネントに初期値として渡したとしても、ビルド時に静的HTMLとして出力することはできませんでした。
実装を工夫すれば静的なHTMLとして出力しつつクライアントサイドではSWRに切り替えるという挙動を実現することができるかもしれませんが、現時点ではユースケースに基づいてどちらかの実装に寄せるというのが適切であると考えます。

全体的な感想

  • オウンドメディアやECサイトのような、リードがメインでインタラクティブな動作が局所的であるようなアプリケーションでは、Server Componentを利用することによる高速化、Suspenseを用いたレンダリングの分割によるユーザ体験の向上など、大きな恩恵を受けることができるでしょう。
  • Consumer Generated Media(CGM)ではデータの作成・変更・削除時にどのような挙動とするかの検討は必要になりますが、上記のような高速化やユーザ体験の向上などは魅力的でしょう。
  • サインインを前提としたり、データをインタラクティブに扱うようなアプリケーションでは、Server ComponentとClient Componentを適切に組み合わせることで全体的な速度を向上させることは可能だと思いますが、Client Componentを用いた従来の開発手法が中心となるでしょう。

全体的にリード主体のアプリケーションに対して恩恵が大きいと考えます。
データをインタラクティブに扱うようなアプリケーションについては、一定の効果はあると思いますが既存のpagesディレクトリを用いたアプリケーションを急いでappディレクトリに移行するほどでもない、という印象です。

appディレクトリはまだ実験的な機能で公開サービスの利用はまだしない方が良いとは思いますが、移行や新規開発での利用を検討している人の参考になれば幸いです。

Discussion