Next.js の App Router で React Server Components & Suspense による段階的なレンダリングを行い Server Action で API を RPC のように呼ぶ

reacttypescriptwebnext.js

今は以前の pages/ ディレクトリベースのルーティングに代わり app/ ディレクトリベースの App Router がデフォルトになっている。

Next.jsのpre-rendering - sambaiz-net

$ npx create-next-app@latest nextjs-vercel-test
✔ Would you like to use the recommended Next.js defaults? › No, customize settings
✔ Would you like to use TypeScript? … No / Yes
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? … No / Yes
? Would you like to use Tailwind CSS? › No / Yes
godgo@sam-thinkpad:~/nextjs-test$ npx create-next-app@latest nextjs-vercel-test
✔ Would you like to use the recommended Next.js defaults? › No, customize settings
✔ Would you like to use TypeScript? … No / Yes
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack? (recommended) … No / Yes
✔ Would you like to customize the import alias (`@/*` by default)? … No / Yes
Creating a new Next.js app in /home/godgo/nextjs-test/nextjs-vercel-test.

Using npm.

Initializing project with template: app-tw
...

$ tree -I node_modules
.
├── README.md
├── app
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── public
│   ├── file.svg
│   ├── globe.svg
│   ├── next.svg
│   ├── vercel.svg
│   └── window.svg
└── tsconfig.json

3 directories, 17 files

App Router では React Server Components (RSC) によりサーバー側でのみ実行されるコンポーネントを書けるため、クライアントの JavaScript バンドルサイズを削減できる。 また Suspense による段階的なレンダリングに対応し、ページの一部を先に表示できる。getServerSideProps() のような関数も不要になった。 デフォルトは RSC で、'use client'; を書くとクライアントで動く通常のコンポーネントになる。 App Router のほかには Server Actions (Functions) もサーバーの API を RPC のように呼べて便利。 この例では zod で入力のバリデーションを行っている。

Suspenseによる段階的なレンダリングと Server Action
// app/demo/page.tsx
import { Suspense } from 'react';
import { Counter } from './counter';

async function SlowComponent() {
  await new Promise(resolve => setTimeout(resolve, 3000));
  return (
    <div className="p-4 bg-green-100 dark:bg-green-900 rounded-lg">
      <h2 className="text-xl font-bold mb-2">Slow Server Component</h2>
      <p>Loaded after 3 seconds</p>
    </div>
  );
}

async function MediumComponent() {
  await new Promise(resolve => setTimeout(resolve, 1500));
  return (
    <div className="p-4 bg-blue-100 dark:bg-blue-900 rounded-lg">
      <h2 className="text-xl font-bold mb-2">Medium Server Component</h2>
      <p>Loaded after 1.5 seconds</p>
    </div>
  );
}

async function FastComponent() {
  await new Promise(resolve => setTimeout(resolve, 500));
  return (
    <div className="p-4 bg-purple-100 dark:bg-purple-900 rounded-lg">
      <h2 className="text-xl font-bold mb-2">Fast Server Component</h2>
      <p>Loaded after 0.5 seconds</p>
    </div>
  );
}

function LoadingSkeleton({ label }: { label: string }) {
  return (
    <div className="p-4 bg-gray-100 dark:bg-gray-800 rounded-lg animate-pulse">
      <div className="h-6 bg-gray-300 dark:bg-gray-600 rounded w-32 mb-2"></div>
      <div className="h-4 bg-gray-300 dark:bg-gray-600 rounded w-48"></div>
      <p className="text-sm text-gray-500 mt-2">Loading...</p>
    </div>
  );
}

// Main page - Server Component
export default function StreamingDemo() {
  return (
    <div className="min-h-screen p-8 bg-white dark:bg-black">
      <div className="max-w-4xl mx-auto">
        <div className="space-y-4">
          <div className="p-4 bg-yellow-100 dark:bg-yellow-900 rounded-lg">
            <h2 className="text-xl font-bold mb-2">Instant Server Component</h2>
            <p>Renders immediately</p>
          </div>

          <Counter />

          <Suspense fallback={<LoadingSkeleton label="Fast Component" />}>
            <FastComponent />
          </Suspense>

          <Suspense fallback={<LoadingSkeleton label="Medium Component" />}>
            <MediumComponent />
          </Suspense>

          <Suspense fallback={<LoadingSkeleton label="Slow Component" />}>
            <SlowComponent />
          </Suspense>
        </div>
      </div>
    </div>
  );
}

// app/demo/counter.tsx
'use client';

import { useState, useTransition } from 'react';
import { saveCount } from './actions';

export function Counter() {
  const [count, setCount] = useState(0);
  const [result, setResult] = useState('');
  const [error, setError] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleSave() {
    setError('');
    setResult('');

    startTransition(async () => {
      const response = await saveCount(count);

      if (response.success) {
        setResult(`Saved ${response.count} at ${response.timestamp}`);
      } else {
        setError(response.error || 'Unknown error');
      }
    });
  }

  return (
    <div className="p-4 bg-pink-100 dark:bg-pink-900 rounded-lg">
      <h2 className="text-xl font-bold mb-2">Client Component + Server Action + Zod</h2>
      <div className="flex items-center gap-4">
        <button
          onClick={() => setCount(count - 1)}
          className="px-4 py-2 bg-pink-500 text-white rounded hover:bg-pink-600"
        >
          -
        </button>
        <span className="text-2xl font-bold">{count}</span>
        <button
          onClick={() => setCount(count + 1)}
          className="px-4 py-2 bg-pink-500 text-white rounded hover:bg-pink-600"
        >
          +
        </button>
        <button
          onClick={handleSave}
          disabled={isPending}
          className="px-4 py-2 bg-pink-700 text-white rounded hover:bg-pink-800 disabled:opacity-50"
        >
          {isPending ? 'Saving...' : 'Save'}
        </button>
      </div>
      {result && <p className="mt-3 text-sm text-green-800 dark:text-green-200">{result}</p>}
      {error && <p className="mt-3 text-sm text-red-900 dark:text-red-200 font-semibold">{error}</p>}
    </div>
  );
}

// app/demo/actions.ts
'use server';

import { z } from 'zod';

const countSchema = z.number().refine((val) => val % 2 === 0, {
  message: 'Count must be an even number',
});

export async function saveCount(count: number) {
  await new Promise(resolve => setTimeout(resolve, 500));

  const result = countSchema.safeParse(count);

  if (!result.success) {
    return { success: false, error: result.error.issues[0].message };
  }

  return { success: true, count, timestamp: new Date().toLocaleTimeString() };
}

参考

React Server Componentsを理解したい