【Next.js】実装ワンポイント

# 概要

この記事では、Next.jsを使った開発において、つい見落としがちだけど、意識したいポイントについて簡単にまとめました。

# 目次

**設計観点**
- [SSR / ISR / SSG を選択する](#ssr--isr--ssg-を選択する)
- [コンポーネントツリーの末端をClient Componentにする](#コンポーネントツリーの末端をclient-componentにする)
- [コンポーネントの設計に迷ったらサードパーティのコンポーネントの実装を見る](#コンポーネントの設計に迷ったらサードパーティのコンポーネントの実装を見る)
- [layout.tsx で 認可をしない](#layouttsx-で-認可をしない)

**データフェッチ観点**
- [Server Componentsでデータをfetchする](#server-componentsでデータをfetchする)
- [データフェッチ層はserver-onlyで保護する](#データフェッチ層はserver-onlyで保護する)
- [Request Memoizationの活用](#request-memoizationの活用)
- [バージョン変更を考慮してfetchの第二引数は必ず指定する](#バージョン変更を考慮してfetchの第二引数は必ず指定する)
- [fetchを利用しない場合はReact.cacheでキャッシュ戦略を最適化する](#fetchを利用しない場合はreactcacheでキャッシュ戦略を最適化する)


**実装観点**
- [画像にnext/imageを使用する](#画像にnextimageを使用する)
- [next/routerではなくnext/navigationからインポートする](#nextrouterではなくnextnavigationからインポートする)
- [メソッド戻り値の指定が不足していないか確認する](#メソッド戻り値の指定が不足していないか確認する)
- [重いコンポーネントのレンダリングは Streaming SSR を活用する](#重いコンポーネントのレンダリングは-streaming-ssr-を活用する)
- [キャッシュ済みのページやデータを更新するにはrevalidatePathとrevalidateTagを使用する](#キャッシュ済みのページやデータを更新するにはrevalidatepathとrevalidatetagを使用する)
- [メタデータの設定が漏れていないか](#メタデータの設定が漏れていないか)
- [ロード画面の作成が漏れていないか](#ロード画面の作成が漏れていないか)

**テスト観点**
- [DOM取得は getByRole を優先する](#dom取得は-getbyrole-を優先する)
- [jest.mockはファイルのトップレベルに書く](#jestmockはファイルのトップレベルに書く)

# 本文
## SSR / ISR / SSG を選択する

Next.jsでは、ページの生成方法として主に3つの戦略があります：サーバーサイドレンダリング（SSR）、インクリメンタル静的再生成（ISR）、静的サイト生成（SSG）です。

ページの要件に応じて、最適な方法を選択しましょう。

| 方式 | パフォーマンス | 更新頻度 | 推奨シーン |
|------|--------------|------------|------------|
| SSG | 高 | 低 | 静的コンテンツ |
| ISR | 中 | 中 | 定期的に更新されるコンテンツ |
| SSR | 低 | 高 | 動的で常に変化するコンテンツ |

**指定方法**

`page.tsx` で指定します。

```ts
// SSR
export const dynamic = 'force-dynamic'

// ISR
export const revalidate = 10; 

// SSG
export const dynamic = 'force-static'
```

## コンポーネントツリーの末端をClient Componentにする

コンポーネントツリーの末端を `Client Components` にすることで、`Client Boundary` を下層に限定することができます。

例えば検索バーを持つヘッダーを実装する際に、ヘッダーごと `Client Components` にするのではなく検索バーの部分だけ`Client Components` として切り出し、ヘッダー自体は `Server Components` に保つことを検討しましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/0b75e05b-6c0b-4bdc-be71-a6e080e765f4.png)


**Compositionパターンを活用する**

上記の方法はシンプルな解決策ですが、どうしても上位層のコンポーネントを `Client Components` にする必要がある場合もあります。その際には `Composition` パターンを活用して、`Client Components` を分離することが有効です。

`Client Components` は `Server Components` をimportすることができませんが、コンポーネントツリーとしてはClient ComponentsのchildrenなどのpropsにServer Componentsを渡すことで、レンダリングが可能です。

🔍 参考: 

https://zenn.dev/akfm/books/nextjs-basic-principle/viewer/part_2_composition_pattern#composition%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3%E3%82%92%E6%B4%BB%E7%94%A8%E3%81%99%E3%82%8B

## コンポーネントの設計に迷ったらサードパーティのコンポーネントの実装を見る

コンポーネントの設計に迷った場合は、よく使われているサードパーティのコンポーネントライブラリの実装を参考にすることが効果的です。

- Radix UI: アクセシビリティに配慮した低レベルコンポーネント
- Chakra UI: 柔軟なカスタマイズが可能なコンポーネント
- shadcn/ui: スタイルとロジックが分離されたヘッドレスUIコンポーネント

## layout.tsx で 認可をしない

特定パス配下の認可を実装する場合、`layout` ではなく `middleware` で行いましょう。

`App Router`においてページとレイアウトは並行にレンダリングされるため、必ずしもレイアウト層に実装した認可チェックがページより先に実行されるとは限りません。

基本的に認可処理は`middleware` で行うようにしましょう。

## Server Componentsでデータをfetchする

基本的にデータの取得は **「Server Components」** で行うようにしましょう。

**Server Components でリクエストを行うことのメリット**
- Next.js サーバーと API サーバーは高速で安定しているため、リクエストが高速処理される
- API のエンドポイントをパブリックなネットワークに公開せずに済む

## データフェッチ層はserver-onlyで保護する

Next.js 14以降では、`server-only`パッケージを使用して、データフェッチ層のモジュールを `Client Components` に誤ってインポートした場合にビルド時エラーを発生させることができます。

データフェッチ層を誤ってクライアントサイドで利用することを防ぐためにも、`server-only` パッケージを利用しましょう。

```typescript
import 'server-only'

export async function getData() {
  const res = await fetch('https://external-service.com/data', {
    headers: {
      authorization: process.env.API_KEY, 
    },
  })

  return res.json()
}
```

## Request Memoizationの活用

`App Router` ではデータフェッチをコロケーションすることが推奨されています。
しかし末端のコンポーネントでデータフェッチを行うと、ページ全体を通して重複するリクエストが発生する可能性が高まります。

`App Router`はこれに対処するため、レンダリング中の同一リクエストをメモ化し排除する`Request Memoization`を実装しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2842674/28ab0212-00f4-4151-8aaa-7e588bb5a007.png)

https://nextjs.org/docs/app/deep-dive/caching#request-memoization

**設計・プラクティス**

`Request Memoization` がリクエストを重複と判定するには、同一URL・同一オプションの指定が必要で、オプションが1つでも異なれば別リクエストが発生します。

オプションの指定ミスにより `Request Memoization` が効かないことを防ぐため、複数のコンポーネントで利用するデータフェッチ処理は分離しましょう。

```typescript
export async function getProduct(id: string) {
  const res = await fetch(`https://dummyjson.com/products/${id}`);
  return res.json();
}
```

## バージョン変更を考慮してfetchの第二引数は必ず指定する

Next.jsの以前のバージョンでは、fetchを使用するとデフォルトのキャッシュ値が`force-cache`になっていました。これはバージョン15で変更され、デフォルトは`cache: no-store`になりました。

つまり、Next.js 15からはデフォルトでキャッシュは使用されなくなりました。明示的にキャッシュを利用したい場合は`cache: 'force-cache'`を指定する必要があります。

今後、バージョンアップデートによる動作変更の影響を受けないようにするため、基本的に引数は明示的に指定しておくようにしましょう。

```tsx
await fetch('https://api.vercel.app/blog', { cache: 'no-store' })
```

## fetchを利用しない場合はReact.cacheでキャッシュ戦略を最適化する

`GraphQL` を使用しているなど、`Next` で拡張された `fetch` を使用できない場合や、より細かいキャッシュ制御が必要な場合、`React.cache`を利用しましょう。

:::note info
Apollo Client を使用している場合は、 `@apollo/client-integration-nextjs`を使用することで自動で `cache` してくれます。

ref. https://github.com/apollographql/apollo-client-integrations
:::

**React.cache**

`React.cache`は、関数の結果をメモ化するためのユーティリティです
（`Server Components` で使用）

同じ引数で呼び出された場合、計算を再実行せずにキャッシュされた値を返します。

https://ja.react.dev/reference/react/cache

## 画像にnext/imageを使用する

Next.jsのプロジェクトでは、`img`タグではなく、`next/image`を使用することが推奨されています。

**Imageタグの機能**
- 画面サイズに応じて画像を最適化（`webp`, `avif` などに自動変換）
- 画像の表示領域を画像がダウンロードされる前から確保し `Cumulative Layout Shift (CLS)` を防止
- 遅延読み込み

```jsx
import Image from "next/image";

<Image
  src={dogImage}
  alt="dogImage"
  width={500}
  height={500}
/>
```

## next/routerではなくnext/navigationからインポートする

Next.js 13以降のApp Router環境では、`next/navigation`モジュールからルーティング関連の機能をインポートする必要があります。

```tsx
// ❌
import { useRouter } from 'next/router';

// ⭕️
import { useRouter } from "next/navigation";
```

これは、App Router向けの新しいナビゲーションAPIが提供されているためです。
間違ったモジュールからインポートすると、機能しないだけでなく、実行時エラーの原因になります。


## メソッド戻り値の指定が不足していないか確認する

`TypeScript` に関連する話題ですが、メソッド戻り値の型指定が必要であるか必ず確認しましょう。
型推論では目的の型と認識されない場合は、明示的に型指定することを忘れないようにしましょう。

```typescript
// ❌ 型が不明確
async function getTodoList() {
  const response = await fetch(`${apiEndpoint}/todos`);
  if (!response.ok) throw new Error('Failed to fetch');
  return await response.json();
}
// => (local function) getTodoList(): Promise<any>

// ✅ 型が明確
async function getTodoList(): Promise<Todo[]> {
  const response = await fetch(`${apiEndpoint}/todos`);
  if (!response.ok) throw new Error('Failed to fetch');
  return await response.json();
}
// => (local function) getTodoList(): Promise<Todo[]>
```

## 重いコンポーネントのレンダリングは Streaming SSR を活用する

Dynamic Renderingを使用する場合でも、特に重いコンポーネントのレンダリングは`<Suspense>`で遅延させ、Streaming SSRを活用しましょう。

**Suspenseによる非同期処理の分離**

Suspenseは、データ取得やコンポーネントの読み込みなどの非同期処理を、レンダリングプロセスから分離します。これにより、重いコンポーネントの処理を待たずに、ページの他の部分を先に表示することができます。

```jsx
import { Suspense } from 'react';
import MyAsyncComponent from './MyAsyncComponent';

export default function Page() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <MyAsyncComponent />
    </Suspense>
  );
}
```

**Streaming SSRのメリット**

`Server Components` と組み合わせることで、HTMLを段階的にストリーミングできます。全てのデータが揃うまで白い画面を表示するよりも、できた部分から表示してユーザーにコンテンツを見せることができます。

`App Router` では `Streaming SSR` をネイティブにサポートしており、重いデータフェッチを伴う `Server Components` のレンダリングを遅延させ、ユーザーにいち早くレスポンスを返し始めることができます。

**デメリットと考慮点**

画面の一部にfallbackを表示しそれが後に置き換えられるため、いわゆるLayout Shiftが発生する可能性があります。特にユーザーインターフェースの重要な部分では、適切なサイズのスケルトンUIやプレースホルダーを用意することでこの問題を軽減できます。

## キャッシュ済みのページやデータを更新するにはrevalidatePathとrevalidateTagを使用する

`SSG` や `ISR` で生成されたページやデータをオンデマンドで更新したい場合は、`revalidatePath`や`revalidateTag`関数を使用します。

- `revalidatePath`: 特定のパスに関連するキャッシュを無効化
- `revalidateTag`: 特定のキャッシュタグに関連するデータを無効化

これらを使うと、新しいコンテンツ作成後や更新後に、関連するキャッシュを即時に更新できます。

```typescript
export async function createPost(id: string) {
  // Postの作成

  revalidatePath('/posts')
  redirect(`/post/${id}`)
}
```

## メタデータの設定が漏れていないか

ページの公開前に、メタデータの設定がされているか確認しておきましょう。

**メタデータへの追加を検討すべき項目**
- `title`: ページタイトル
- `description`: ページの概要
- `robots`: 検索エンジンクローラーの挙動制御
- `openGraph`: SNS共有時の表示設定（Twitter, Facebook固有のカスタム可能）

```typescript
import { Metadata } from "next";

// Static
export const metadata: Metadata = {
  title: "Profile",
  description: "Profile of PenBlog App",
};

// Dynamic
export async function generateMetadata({ params }): Promise<Metadata> {
  const product = await getProduct(params);

  if (!product) return {};
  
  return {
    title: product.seo.title || product.title,
    description: product.seo.description || product.description,
    robots: {
      index: indexable,
      follow: indexable,
      googleBot: {
        index: indexable,
        follow: indexable
      }
    },
    openGraph: url
      ? {
          images: [
            {
              url,
              width,
              height,
              alt
            }
          ]
        }
      : null
  };
}
```

## ロード画面の作成が漏れていないか
ユーザー体験向上のためにロード画面（`loading.tsx`）を作成しているプロジェクトも多いかと思います。

基本的には **「ページ単位でのスケルトン表示」** or **「アプリケーション共通のロード画面（スピナーなど）」** するどちらかの方法を採用しているかt

前者を採用している場合、実装漏れがないか確認しておきましょう。

## DOM取得は getByRole を優先する

`React Testing Library` を使用したテストでは、DOM要素の取得方法には優先順位があります。この優先順位はユーザーの実際の操作体験に近づけることを基準としています。

要素を取得するためのマッチャーとして、`getByRole` を優先使用するようにしましょう！

1. `getByRole`
2. `getByLabelText`
3. `getByPlaceholderText`
4. `getByText`
5. `getByDisplayValue`

```tsx
// ❌
const button = screen.getByTestId('submit-button');

// ✅
const button = screen.getByRole('button', { name: '送信' });
```

🔍 参考: 

https://testing-library.com/docs/queries/about/#priority

## jest.mockはファイルのトップレベルに書く

Jestでモックする際、`jest.mock` はファイルのトップレベルに記述しましょう。

トップレベルに記載していない場合についても、**Jest** は自動的に `jest.mock` 呼び出しをモジュールの先頭（インポートの前）にホイスティングします。

> If you're using ES module imports then you'll normally be inclined to put your import statements at the top of the test file. But often you need to instruct Jest to use a mock before modules use it. For this reason, Jest will automatically hoist jest.mock calls to the top of the module (before any imports). To learn more about this and see it in action, see this repo.

🔍 参考: 

https://jestjs.io/docs/29.4/manual-mocks#using-with-es-module-imports

# まとめ

以上です！
今後も定期的に更新していこうと思います🙋‍♂️
（コメントも大歓迎です）

良いエンジニアライフを！