🐙

Next.js app directoryを触ってみる

に公開

Routing Fundamentals

Next.js 13ではReact Server Components上に構築され、レイアウト、ネストされたルーティング、ロード、エラー処理などをサポートするApp Routerが導入されました。
この新しいルーティングモデルの基本的な考え方について見ていきます。

用語解説

ドキュメント内で使用される用語について簡単なリファレンスを紹介します。

  • ツリー:階層構造を視覚化するための規約。例えば、親と子のコンポーネントを持つコンポーネントツリー、フォルダー構造など。
  • サブツリー: ツリーの一部で、新しいルート(最初)から始まり、リーフ(最後)で終わる。
  • ルート(Root): ルートレイアウトなど、ツリーまたはサブツリーにおける最初のノード。
  • リーフ: サブツリー内のノードで、URLパスの最後のセグメントなど、子ノードを持たないもの。

blobから始まるまとまりをサブツリーA、dashboardから始まるまとまりをサブツリーBとすると、サブツリーAのルートセグメントがblob、サブツリーBのルートセグメントがdashboardとなります。

  • URL セグメンテーション:スラッシュで区切られたURLパスの一部。
  • URLパス:ドメインの後に来るURLの一部(セグメントで構成される)。

アプリのディレクトリ

ルートのappディレクトリのpage.tsxで表示させるページを作成します。

// サーバーコンポーネントとして認識されている
export default function Page() {
  return (
    <main>
      <div className="m-10 text-center">Hellow, World</div>
    </main>
  )
}

app直下のlayoutファイルはルートレイアアウトとみなされ、_app.tsxの内容を書くことができる。
layout.tsx

import './globals.css'

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

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

head.tsxではタイトルやメタ、リンク、スクリプトなどを設定する。

export default function Head() {
  return (
    <>
      <title>Nextjs 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" />
    </>
  )
}

server componentにおけるデータフェッチ

client componentの場合はuseEffectやTanstackQuery等のデータフェッチライブラリを使う必要があるが、server componentsではコンポーネント単位でasync/awaitが可能となり、簡単な記述でデータフェッチを書くことができる。

export default async function NotesList() {
  // 
  const notes = await fetchNotes()
  return (
    <div>
    ...

データ取得処理

async function fetchNotes() {
  const res = await fetch(`${process.env.url}/rest/v1/notes?select=*`, {
    headers: new Headers({
      apikey: process.env.apikey as string,
    }),
    cache: 'no-store',
  })
  if (!res.ok) {
    throw new Error('Failed to fetch data in server')
  }
  const notes: Note[] = await res.json()
  return notes
}

fetchのキャッシュオプションについて

個々のフェッチリクエストによって設定されたrevalidate値は上書きされることはない。

LoadingとErrorファイルについて

セグメントのファイルにloading.tsxerror.tsxを追加するとnext.jsが自動的にページコンポーネントをラップする形でsuspenseErrorBoundaryを追加してくれ、この中で表示させるものをコンポーネントとして作成することができる。

<ErrorBoundary  fallback={<Error />}> error.tsx
  <Suspense fallback={<Loading />}> loading.tsx
    <Page />
  </Suspense>
</ErrorBounary>

loading.tsx

ページコンポーネントをラップする形でサスペンスを追加するため、レイアウトコンポーネントのようなページコンポーネントの外側にあるものはそのまま表示される形になる。

import Spinner from './components/spinner'

export default function Loading() {
  // loading用のスピナー表示
  return <Spinner />
}

error.tsx

server componentで発生したエラーはクライアント側で表示する必要がある。
1行目にuse clientを追加することでclient componentとして扱うことができる。

'use client'

export default function Error({ error }: { error: Error }) {
  return (
    <div>
      <p className="mt-6 text-center text-red-500">
        Data fetching in server failed
      </p>
    </div>
  )
}

windows固有の不具合あり

"next": "13.2.5-canary.34"で進めていたところwindows固有の不具合に遭遇。"next": "13.3.1-canary.4"で修正済みのようなのでバージョンアップで対応。

server componentとclient componentの特徴と使い分けについて

server component

appディレクトリ内のコンポーネントはデフォルトですべてReact Server Components(RSC)となる。

  • サーバー側でレンダリングされる(クライアント側にJavaScriptが送られない)
  • Data fetchにasync functionを使用できる
  • Secure keyを使用可能
  • BrouswerAPIは使用不可
  • useState、useEffectは使用不可
  • Event listener(onClick等)は使用不可

client component

クライアント側のインタラクティブ性を追加できる。Next.jsではサーバーでプリレンダリングされ、クライアントでハイドレーションされる。

  • ブラウザでJavaScriptが実行される
  • Data fetchにasync functionを使用できない(useEffect、ReactQuery、SWR、use等を使う必要がある)
  • Secure keyを使用不可
  • useState、useEffect等を使用可能
  • Event listener(onClick等)を使用可能

使い分け

server componentclient componentの使い分けを簡単にするために、client componentが必要になるまではserver component(appディレクトリのデフォルト)を使うことをおすすめする。

server component

  • Fetch Data(learn more)
  • シークレットなキーを使う必要がある場合
  • npmパッケージで大きいものはserver componentで消費する(JavaScriptのバンドルを小さくすることでパフォーマンス改善が見込める)

clinent component

  • event listenersを使う必要がある場合

  • useState、useReducer、useEffectなどを使う必要がある場合

  • browser APIを使う必要がある場合

server componentclient componentにインポートする

server component内でclient componentをインポートすることはできるが、client component内にserver componentをインポートすることには制限がある。

app/client-component.js
'use client';

// ❌ This pattern will not work. You cannot import a Server
// Component into a Client Component
import ServerComponent from './ServerComponent';

export default function ClientComponent() {
  return (
    <>
      <ServerComponent />
    </>
  );
}

直接インポートせずに、client componentchildrenpropsとして渡すようにする必要がある。

app/client-component.js
'use client';

export default function ClientComponent({children}) {
  return (
    <>
      {children}
    </>
  );
}
app/page.js
// ✅ This pattern works. You can pass a Server Component
// as a child or prop of a Client Component.
import ClientComponent from "./ClientComponent";
import ServerComponent from "./ServerComponent";

// Pages are Server Components by default
export default function Page() {
  return (
    <ClientComponent>
      <ServerComponent />
    </ClientComponent>
  );
}

streaming HTML

loading.jsxを追加するとページ全体をラップする形でsuspenseが追加されるため、ページ内に含まれるserver componentが解決した後ページ全体のコンポーネントが表示される。そのため、server componentの解決を待たずに表示できるものも表示が遅れてしまっている。
streaming HTMLを使うことで、クライアント側にストリーミングでHTMLが表示、必要なJavaScriptがハイドレートされて、すぐにインタラクティブにすることができる。

ストリーミングとは?

SSRでは、ユーザーがページを見て操作できるようになるまでに、一連の手順を完了させる必要がある。

  1. あるページのすべてのデータがサーバーに取り込まれる
  2. サーバーはそのページのHTMLをレンダリングする
  3. ページのHTML、CSS、JavaScriptがクライアントに送信される
  4. 生成されたHTMLとCSSを使用して、非インタラクティブなユーザーインターフェイスが表示される
  5. Reactがユーザーインターフェイスをインタラクティブにするためにハイドレートされる

ページを表示する前にこれらの手順を行う必要があるため、ページ表示に時間がかかることがある。

ストリーミングでは、ページのHTMLを小さな塊に分解し、その塊をサーバーからクライアントに順次送信し、インタラクティブでないページをなるべく早く表示していくことができる。

supabaseの認証とCRUD操作を追加する

supabaseの認証が必要な操作を実装する。

Server Component

  • READ

Server Componentの場合は、サーバー側にアクセストークンを渡す必要がある。
Next.jsでHeadersCookiesが用意されており、これらを使ってServer Componentに対してヘッダーやクッキーの値を渡す。

Client Component

  • CREATE
  • UPDATE
  • DELETE

Client Componentの場合は、ユーザーがログインした際にsupabaseのAPIによって自動的にアクセストークンが付与されるため、簡単に認証を通すことができる。

Server ComponentClient Componentのアクセストークンをどのように同期させるか?

別のユーザーがログインした際、ブラウザ側のアクセストークンが新しいものに変わり、その新しいアクセストークンをサーバー側にも渡す必要がある。
そのためには、ユーザーがログインしたときに、クライアントが持っているアクセストークンとサーバー側にあるアクセストークンを比較し、値が異なる場合はHeadersを使ってクライアント側からサーバー側に新しいアクセストークンを渡して再度Server Componentを実行する。

認証関係のセグメントを追加する

auth/page.tsxは認証関係のセグメントのUIを表示する役割を持つため、Authコンポーネントを表示する。(AuthPageはサーバーコンポーネント)

app/auth/page.tsx
import Auth from '../components/auth'

export default async function AuthPage() {
  return (
    <main>
      <Auth />
    </main>
  )
}

auth/layout.tsxではクライアントからServer Componentに対してアクセストークンを渡すために必要な実装を行う。
createServerComponentSupabaseClientServer Componentで使用できるsupabaseのインスタンスを生成するためのもの。

app/auth/layout.tsx
import { headers, cookies } from 'next/headers'
import SupabaseListener from '../components/supabase-listener'
import { createServerComponentSupabaseClient } from '@supabase/auth-helpers-nextjs'
import { Database } from '../../../database.types'

export default async function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  const supabase = createServerComponentSupabaseClient<Database>({
    headers,
    cookies,
  })
  // サーバー側に保存されているsession情報を取得
  const { data: { session } } = await supabase.auth.getSession()
  return (
    <>
      {/* サーバー側から取得したsessionのアクセストークンをSupabaseListenerのpropsとして渡す */}
      <SupabaseListener accessToken={session?.access_token} />
      {children}
    </>
  )
}

Supabase Auth with Nextjs

Client Componentで使用するsupabaseのインスタンスを作成してexportしておく。

supabase.ts
import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs'.
import { Database } from '../database.types'

export default createBrowserSupabaseClient<Database>()

createBrowserSupabaseClientは、ブラウザに新しいsupabaseクライアントオブジェクトを作成する関数で、ブラウザからSupabase APIとやりとりするための新しいSupabaseクライアントオブジェクトが作成される。

ユーザーのセッション情報を監視

アクセストークンを受け取るClient ComponentであるSupabaseListenerを作成し、その中でユーザー情報の更新やクライアント側とサーバー側でアクセストークンが異なる場合に、Server Componentを再実行する処理を行う。

supabase-listener.tsx
'use client'

export default function SupabaseListener({
  accessToken,
}: {
  accessToken?: string
}) {
  const router = useRouter()
  const { updateLoginUser } = useStore() // globalstate
  useEffect(() => {
    const getUserInfo = async () => {
      const { data } = await supabase.auth.getSession()
      if (data.session) {
        updateLoginUser({
          id: data.session?.user.id,
          email: data.session?.user.email!,
        })
      }
    }
    getUserInfo()
    supabase.auth.onAuthStateChange((_, session) => {
      updateLoginUser({ id: session?.user.id, email: session?.user.email! })
      if (session?.access_token !== accessToken) {
        router.refresh()
      }
    })
  }, [accessToken])
  return null
}

以下の処理で、クライアント側とサーバー側のアクセストークンが異なる場合はrouter.refresh()Server Componentを再実行する。
ここで、Headersを使って最新のアクセストークンの値をServer Componentに渡し、Server Componentはそのアクセストークンを使ってsupabaseにデータ取得しに行く形となる。
このときに

supabase.auth.onAuthStateChange((_, session) => {
  updateLoginUser({ id: session?.user.id, email: session?.user.email! })
  if (session?.access_token !== accessToken) {
    router.refresh()
  }
})

buildでエラー発生

上記作業中、ビルドでエラーが発生。

いったんIssueをもとに"next": "13.1.6"にダウングレードで回避。

Discussion