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のキャッシュオプションについて
-
force-cache
(default)
getStaticPropsと同じ挙動 -
no-store
getServerSidePropsと同じ挙動 -
{ next: { revalidate: number } }
ISR(Incremental Static Regeneration)と同じ挙動
個々のフェッチリクエストによって設定されたrevalidate値は上書きされることはない。
LoadingとErrorファイルについて
セグメントのファイルにloading.tsx
、error.tsx
を追加するとnext.jsが自動的にページコンポーネントをラップする形でsuspense
やErrorBoundary
を追加してくれ、この中で表示させるものをコンポーネントとして作成することができる。
<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 component
とclient 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 component
をclient component
にインポートする
server component
内でclient component
をインポートすることはできるが、client component
内にserver component
をインポートすることには制限がある。
'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 component
のchildren
かprops
として渡すようにする必要がある。
'use client';
export default function ClientComponent({children}) {
return (
<>
{children}
</>
);
}
// ✅ 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では、ユーザーがページを見て操作できるようになるまでに、一連の手順を完了させる必要がある。
- あるページのすべてのデータがサーバーに取り込まれる
- サーバーはそのページのHTMLをレンダリングする
- ページのHTML、CSS、JavaScriptがクライアントに送信される
- 生成されたHTMLとCSSを使用して、非インタラクティブなユーザーインターフェイスが表示される
- Reactがユーザーインターフェイスをインタラクティブにするためにハイドレートされる
ページを表示する前にこれらの手順を行う必要があるため、ページ表示に時間がかかることがある。
ストリーミングでは、ページのHTMLを小さな塊に分解し、その塊をサーバーからクライアントに順次送信し、インタラクティブでないページをなるべく早く表示していくことができる。
supabaseの認証とCRUD操作を追加する
supabase
の認証が必要な操作を実装する。
Server Component
- READ
Server Component
の場合は、サーバー側にアクセストークンを渡す必要がある。
Next.jsでHeadersやCookiesが用意されており、これらを使ってServer Component
に対してヘッダーやクッキーの値を渡す。
Client Component
- CREATE
- UPDATE
- DELETE
Client Component
の場合は、ユーザーがログインした際にsupabase
のAPIによって自動的にアクセストークンが付与されるため、簡単に認証を通すことができる。
Server Component
とClient Component
のアクセストークンをどのように同期させるか?
別のユーザーがログインした際、ブラウザ側のアクセストークンが新しいものに変わり、その新しいアクセストークンをサーバー側にも渡す必要がある。
そのためには、ユーザーがログインしたときに、クライアントが持っているアクセストークンとサーバー側にあるアクセストークンを比較し、値が異なる場合はHeaders
を使ってクライアント側からサーバー側に新しいアクセストークンを渡して再度Server Component
を実行する。
認証関係のセグメントを追加する
auth/page.tsxは認証関係のセグメントのUIを表示する役割を持つため、Authコンポーネントを表示する。(AuthPage
はサーバーコンポーネント)
import Auth from '../components/auth'
export default async function AuthPage() {
return (
<main>
<Auth />
</main>
)
}
auth/layout.tsx
ではクライアントからServer Component
に対してアクセストークンを渡すために必要な実装を行う。
createServerComponentSupabaseClient
はServer Component
で使用できるsupabase
のインスタンスを生成するためのもの。
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しておく。
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
を再実行する処理を行う。
'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