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 で入力のバリデーションを行っている。

// 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() };
}