8
1

Next.jsによるレンダリング(CSR, SSR, SSG)を、実際にアプリを作って試してみた

Posted at

はじめに

このたび、ANGEL Calendarの企画に参加しております!
この記事は9日目の記事となります。
他の方の記事は、2024-ANGEL-Dojoの記事一覧から是非チェックしてみてください!

なぜこの記事を書いたのか

今回のANGEL Dojoではせっかくなので使ったことがない新しいフレームワークを使ってみようということになり(他にも選定理由はありますが)、Next.jsを採用することにしました。
Next.jsについて調べているときに、いろいろレンダリング方法を使い分けることができるらしいということを知り、簡単なアプリを作って実際どのようにレンダリングされるのか検証してみることにしました。

(普段の業務ではVue.jsでクライアントを実装しているので、Reactもあまり触ったことがない程度の知識で検証していきます)

Next.jsが提供するレンダリング

簡単にまとめると、以下のような感じです。

  • SSR: サーバサイドレンダリング
    • ページリクエストごとにサーバーでHTMLを生成し、クライアントに送信する手法
    • クライアントのマシンスペックに依存しないため、すべてのユーザに安定した表示速度を提供できる
  • SSG: 静的サイト生成
    • ビルド時にHTMLファイルを生成し、リクエストがあった際はその静的ファイルを返す手法
    • パフォーマンスに優れた静的ページ(更新頻度が低いページ)に適している
  • CSR: クライアントサイドレンダリング
    • ページのHTMLは空の状態でクライアントに送信され、JavaScriptがブラウザ内でコンテンツを生成する手法
    • インタラクティブな表示を必要とするページに適している
  • ISR: インクリメンタル静的再生成
    • SSGと同様にビルド時に生成した静的サイトを返すが、指定された時間間隔で再生成を行う手法
    • たまに更新がある場合にも自動で反映できる

Next.jsでは、上記の4つのレンダリング方法をページやコンポーネントごとに選んで実装することができます。
詳しい内容やNext.jsでの実装方法は、以下の記事に分かりやすくまとめられているので参考にしてみてください。

検証用のアプリ実装

とりあえずサンプルとしてよく作られるTodoアプリをNext.jsで実装していきます。

実装方針

  • SSR、CSR、SSGで生成されるページをそれぞれ実装する
    • ISRはほとんどSSGと同じなので、今回はパス
  • コードは基本的に生成AIに書いてもらう
    • 検証目的なので、とりあえず動けばそれで良しとする
    • ディレクトリ構成やAPI設計は、ベストプラクティスではなくても簡単に実装できる方法を選択
    • スタイルは生成AIにTailwind CSSで雑に整えてもらう
  • ルーティングはApp Routerを使う
    • なんとなく新しい方を使ってみたいので
  • 実装するページは以下の3つ
    • トップページ
    • Todo一覧ページ
    • Todo作成ページ

開発環境

今回利用している各ツールのバージョンは以下のとおりです。

  • yarn: v1.22.22
  • Node.js: v20.9.0
  • Next.js: v14.2.5

環境構築

  1. 以下のコマンドでNext.jsプロジェクトを作成
    yarn create next-app --typescript
    
  2. プロジェクト名や設定などいろいろ聞かれるが、以下のように選択(ほぼデフォルトでOK)
    • 今回はimport aliasの設定のみデフォルトのNoではなく、@/*に設定
    ✔ What is your project named? … next-demo
    ✔ Would you like to use ESLint? … Yes
    ✔ Would you like to use Tailwind CSS? … Yes
    ✔ Would you like to use `src/` directory? … No
    ✔ Would you like to use App Router? (recommended) … Yes
    ✔ Would you like to customize the default import alias (@/*)? … Yes
    ✔ What import alias would you like configured? … @/*
    

これだけで、Next.jsのアプリを実装する準備は完了です(めっちゃ簡単)

各ページ実装

ChatGPT-4oと何回か壁打ちし、最終的に以下のような実装になりました。

トップページ

トップページは、SSGでレンダリングします。
表示する要素は、以下の通りです。

  • "Top Page"の文字列
  • 現在日時
  • Todo一覧ページへの遷移ボタン
app/page.tsx
import Link from "next/link";

export default function Home() {
  console.log('Top: SSG')

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <div className="text-4xl font-bold mb-8">Top Page</div>
      <div className="text-2xl font-bold mb-8">{new Date().toLocaleString()}</div>
      <Link href="/todos">
        <p className="px-6 py-3 bg-blue-500 text-white rounded-lg shadow-lg hover:bg-blue-600 transition duration-300">
          Go to TodoList
        </p>
      </Link>
    </div>
  );
}

Todo一覧ページ

Todo一覧ページは、SSRとCSRを組み合わせて実装してみます。
表示する要素は、以下の通りです。

  • ページタイトル
  • Topページへの遷移ボタン
  • Todo作成ページへの遷移ボタン
  • Todoリスト
    • ID、Title、Completed(完了フラグ)の3つの要素を持つ
    • 各TodoのCompletedのみ、コンポーネントを分割してCSRでレンダリングする
app/types/index.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}
app/api/todos/route.ts
import { Todo } from '@/app/types';
import { NextRequest, NextResponse } from "next/server"

let todos: Todo[] = [
  { id: 1, title: 'Todo 1', completed: false },
  { id: 2, title: 'Todo 2', completed: true },
];
let nextId = 3;

const GET = () => {
  return NextResponse.json(todos);
}

const POST = async (req: NextRequest) => {
  const body = await req.json();
  const newTodo: Todo = { id: nextId++, title: body.title, completed: false };
  todos.push(newTodo);
  return NextResponse.json(newTodo);
}

const PUT = async (req: NextRequest) => {
  const body = await req.json();
  todos[body.id - 1].completed = body.completed;
  return NextResponse.json(todos[body.id - 1]);
}

export { GET, POST, PUT };
app/todos/Completed.tsx
'use client'

import { useState } from "react";

const Completed = ({ id, isCompleted }: { id: number, isCompleted: boolean }) => {
  console.log(`CSR Completed: ${isCompleted}`)
  const [completed, setCompleted] = useState(isCompleted);

  const handleCompleted = async () => {
    console.log('handleCompleted')
    await fetch('/api/todos', {
      method: 'PUT',
      body: JSON.stringify({ id: id, completed: !completed }),
    })
    .then(() => {
      setCompleted(!completed)
    })
    .catch(() => {
      alert('Failed to update Todo');

    })
  }
  return (
    <div>
      <button onClick={handleCompleted}>{completed ? '' : '🔲'}</button>
    </div>
  )
}

export default Completed
app/todos/page.tsx
import Link from 'next/link';
import { Todo } from '@/app/types';
import Completed from './Completed';

export default async function Todos() {
  console.log('TodoList: SSR');
  const res = await fetch('http://localhost:3000/api/todos', {
    cache: 'no-store', // 最新のデータを常に取得
  });
  const todos: Todo[] = await res.json();

  return (
    <div>
      <h1 className="text-2xl font-bold text-center mb-4">Todo List</h1>
      <div className="flex justify-between mb-4 ml-4 mr-4">
        <Link href="/">
          <p className="inline-block px-6 py-3 bg-blue-500 text-white rounded-lg shadow-lg hover:bg-green-600 transition duration-300">
            Back to Top
          </p>
        </Link>
        <Link href="/todos/create">
          <p className="inline-block px-6 py-3 bg-green-500 text-white rounded-lg shadow-lg hover:bg-green-600 transition duration-300">
            Create
          </p>
        </Link>
      </div>
      <table className="min-w-full border border-gray-200">
        <thead>
          <tr>
            <th className="px-4 py-2 border-b-2 border-gray-200 text-left">ID</th>
            <th className="px-4 py-2 border-b-2 border-gray-200 text-left">Title</th>
            <th className="px-4 py-2 border-b-2 border-gray-200 text-left">Completed</th>
          </tr>
        </thead>
        <tbody>
          {todos.map((todo) => (
            <tr key={todo.id} className="hover:bg-gray-700">
              <td className="px-4 py-2 border-b border-gray-200">{todo.id}</td>
              <td className="px-4 py-2 border-b border-gray-200">{todo.title}</td>
              <td className="px-4 py-2 border-b border-gray-200">
                <Completed id={todo.id} isCompleted={todo.completed} />
              </td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

Todo作成ページ

Todo作成ページは、CSRで実装しています。
表示する要素は、以下の通りです。

  • ページタイトル
  • 入力フォーム
    • Title入力欄
    • 作成ボタン
app/todos/create/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function CreateTodo() {
  console.log('TodoCreate: CSR');
  
  const [title, setTitle] = useState('');
  const router = useRouter();

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    await fetch('/api/todos', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ title }),
    })
    .then(() => {
      // Todo一覧ページにリダイレクト
      router.push('/todos');
      router.refresh();
    }).catch(() => {
      alert('Failed to create Todo');
    });
  };

  return (
    <div className="flex flex-col items-center justify-center min-h-screen">
      <h1 className="text-2xl font-bold mb-4">Create New Todo</h1>
      <form onSubmit={handleSubmit} className="w-full max-w-md bg-white p-8 rounded-lg shadow-md">
        <div className="mb-4">
          <label htmlFor="title" className="block text-gray-700 text-sm font-bold mb-2">
            Title
          </label>
          <input
            id="title"
            type="text"
            value={title}
            onChange={(e) => setTitle(e.target.value)}
            className="w-full px-3 py-2 border rounded-lg text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-400"
            required
          />
        </div>
        <button
          type="submit"
          className="w-full px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition duration-300"
        >
          Create
        </button>
      </form>
    </div>
  );
}

ビルドして検証

以下のコマンドでビルドします。

yarn build

ビルドが完了したら、以下のコマンドでサーバを起動し、ブラウザでhttp://localhost:3000にアクセスします。

yarn start

トップページの確認

スクリーンショット 2024-09-07 19.36.26.png
app/page.tsxに仕込んでおいたconsole.log('Top: SSG')がビルド時に出力されていることから、SSGでレンダリングされていることが確認できました。(2回ログが出力されるのは謎ですが)
何回ページをリロードしても日時が変わらないのも、ビルド時にレンダリングされた時刻がそのまま表示され続けているためです。

Todo一覧ページの確認

スクリーンショット 2024-09-07 19.36.58.png
同様に、ページに仕込んでおいたログを確認していきます。
ページを表示した際、console.log('TodoList: SSR')の出力はサーバ側のログに出力されています。
一方、Completedコンポーネントのconsole.log(`CSR Completed: ${isCompleted}`)や、完了アイコンをクリックしたときのconsole.log('handleCompleted')は、ブラウザのデベロッパーツールのコンソールに表示されています。
このことから、CompletedコンポーネントのみCSRでレンダリングされていて、それ以外のページ全体はSSRでレンダリングされていることが分かります。
(Todo一覧ページでリロードしたとき、console.log(`CSR Completed: ${isCompleted}`)の出力がサーバとブラウザのコンソールの両方に出力されているというのも謎です、実装ミスなのかNext.jsの仕様なのか...:thinking:

Todo作成ページの確認

スクリーンショット 2024-09-07 19.37.10.png
こちらも同様に、ページを表示した際にconsole.log('TodoCreate: CSR')がブラウザのコンソールに出力されることを確認します。
また、Titleの入力欄に1文字入力するたびconsole.log('TodoCreate: CSR')が出力されることから、useStateによる再レンダリングがクライアント側で行われていることも分かります。

所感

さまざまな要件によって変わってくると思いますが、とりあえずパフォーマンスの観点からは基本的にSSG(ISR)、SSRで実装することを検討し、インタラクティブな操作やリアルタイム性のある表示をする必要がある場合はCSRで実装する、という方針で良いのかなと考えました。

まとめ

今回の記事ではNext.jsのレンダリングにフォーカスしてみましたが、そもそもNext.js(React)のキャッチアップとしても有意義な検証になったと思います。
当初はこのアプリをAWS Amplify Gen2でデプロイするところまでやって検証することを考えていましたが、記事のボリュームが膨れ上がりそうなのと、AmplifyについてはANGEL Calendar参加者の他の方がいろいろ記事を書いていただいているので、今回そこまではやらないことにしました:sweat_smile:

以上、ご覧いただきありがとうございました!
ANGEL Calendarは明日以降も毎日投稿されますので、乞うご期待!!

参考

8
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
1