Lecture 1Next.jsとは何か — React開発の次のステップ
12:00
Next.jsとは何か — React開発の次のステップ
Reactだけでは足りない理由
ReactはUIライブラリとして優秀ですが、実際のWebアプリケーションを作るには追加で多くの決断と設定が必要です。
- ルーティング(ページ遷移)をどう実装するか?
- サーバーサイドレンダリング(SSR)はどう設定するか?
- APIエンドポイントはどこに書くか?
- SEO対策はどうするか?
- パフォーマンス最適化(コード分割、画像最適化)は?
Reactはこれらに対して「自分で選んでね」というスタンスです。Next.jsは、これらの問題に対するVercelの回答 — Reactのフルスタックフレームワーク です。
Next.jsの全体像
Next.jsはVercel社が2016年に公開したReactフレームワークです。State of JS 2024で最も使われているメタフレームワーク1位を記録しています。
React(UIライブラリ)
└── Next.js(フルスタックフレームワーク)
├── ファイルベースルーティング
├── サーバーコンポーネント(RSC)
├── API Routes / Server Actions
├── 画像・フォント最適化
├── SSR / SSG / ISR
└── ミドルウェア
なぜNext.jsが選ばれるのか
| 課題 | 素のReact | Next.js |
|---|---|---|
| ルーティング | react-router等を別途導入 | ファイルベースで自動 |
| SEO | クライアントレンダリングで不利 | サーバーレンダリングで有利 |
| API | 別途Express等が必要 | Route Handlers で組み込み |
| パフォーマンス | 手動最適化が必要 | Image, Font, 自動コード分割 |
| デプロイ | 自分で構成 | Vercelでゼロ設定デプロイ |
環境構築
# プロジェクト作成
npx create-next-app@latest my-app
# 対話形式で設定を選択
# ✔ Would you like to use TypeScript? → Yes
# ✔ Would you like to use ESLint? → Yes
# ✔ Would you like to use Tailwind CSS? → Yes
# ✔ Would you like to use `src/` directory? → Yes
# ✔ Would you like to use App Router? → Yes
# ✔ Would you like to customize the import alias? → No
cd my-app
npm run dev
# http://localhost:3000 で開発サーバー起動
App Router — Next.js 13以降の標準
Next.js 13で導入されたApp Routerは、ファイルシステムベースのルーティングとReact Server Componentsを統合した新しいアーキテクチャです。
src/app/
├── layout.tsx ← ルートレイアウト(全ページ共通)
├── page.tsx ← / (トップページ)
├── about/
│ └── page.tsx ← /about
├── blog/
│ ├── page.tsx ← /blog(一覧)
│ └── [slug]/
│ └── page.tsx ← /blog/hello-world(動的ルート)
└── globals.css
ルール: page.tsx を含むフォルダがルートになります。layout.tsx は子ページを共通レイアウトでラップします。
最初のページ
// src/app/page.tsx
export default function HomePage() {
return (
<main>
<h1>Next.jsへようこそ</h1>
<p>フルスタックReact開発を始めましょう。</p>
</main>
);
}
// src/app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>このサイトについて</h1>
<p>Next.jsで構築されたWebアプリケーションです。</p>
</main>
);
}
ファイルを作るだけでルーティングが完成します。設定ファイルは不要です。
レイアウト
// src/app/layout.tsx — ルートレイアウト
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "My Next.js App",
description: "Next.js入門で作成したアプリケーション",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
<header>
<nav>
<a href="/">ホーム</a>
<a href="/about">About</a>
<a href="/blog">ブログ</a>
</nav>
</header>
<main>{children}</main>
<footer>© 2026 My App</footer>
</body>
</html>
);
}
レイアウトはネスト可能です。app/blog/layout.tsx を作れば、ブログセクション専用のサイドバーを追加できます。
サーバーコンポーネント vs クライアントコンポーネント
App Routerの最大の特徴は React Server Components(RSC) です。
// サーバーコンポーネント(デフォルト)
// — サーバーで実行され、HTMLとしてクライアントに送信
// — データベースに直接アクセスできる
// — useStateやuseEffectは使えない
export default async function UserList() {
const users = await fetch("https://api.example.com/users").then(r => r.json());
return (
<ul>
{users.map((user: { id: number; name: string }) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
// クライアントコンポーネント — ファイル先頭に "use client" を記述
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(prev => prev + 1)}>
クリック数: {count}
</button>
);
}
| 特性 | サーバーコンポーネント | クライアントコンポーネント |
|---|---|---|
| デフォルト | はい | "use client" が必要 |
| useState/useEffect | 不可 | 可能 |
| イベントハンドラー | 不可 | 可能 |
| データフェッチ | async/await で直接 | useEffect 内で |
| バンドルサイズ | JSを送信しない | JSがクライアントに送信 |
設計原則: インタラクションが必要なコンポーネントだけを "use client" にし、それ以外はサーバーコンポーネントのままにする。これがNext.jsのパフォーマンス最適化の基本戦略です。
演習:最初のNext.jsアプリ
# 課題:
# 1. create-next-app でプロジェクトを作成
# 2. 3つのページを作成: /, /about, /contact
# 3. 共通のナビゲーションを layout.tsx に実装
# 4. npm run dev で動作確認
次回は、Next.jsのルーティングを深掘りします。
参考文献
- Next.js公式ドキュメント: https://nextjs.org/docs
- Vercel Blog - App Router: https://nextjs.org/blog/next-13-4
- React Server Components RFC: https://github.com/reactjs/rfcs/pull/188
Lecture 2ルーティングとナビゲーション — ファイルベースの直感的な設計
13:00
ルーティングとナビゲーション — ファイルベースの直感的な設計
App Routerのファイル規約
App Routerでは、ファイル名に特別な意味があります。
| ファイル名 | 役割 |
|---|---|
page.tsx |
ルートのUI(URLに対応するページ) |
layout.tsx |
共有レイアウト(子ルートをラップ) |
loading.tsx |
ローディングUI(Suspense境界) |
error.tsx |
エラーUI(Error Boundary) |
not-found.tsx |
404ページ |
template.tsx |
再マウントされるレイアウト |
動的ルーティング
URLの一部をパラメータとして受け取るルートです。
// src/app/blog/[slug]/page.tsx
// URL: /blog/hello-world → params.slug = "hello-world"
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
return (
<article>
<h1>記事: {slug}</h1>
</article>
);
}
ネストした動的ルート
src/app/
└── shop/
└── [category]/
├── page.tsx ← /shop/electronics
└── [productId]/
└── page.tsx ← /shop/electronics/123
// src/app/shop/[category]/[productId]/page.tsx
interface Props {
params: Promise<{ category: string; productId: string }>;
}
export default async function ProductPage({ params }: Props) {
const { category, productId } = await params;
return <h1>{category} カテゴリの商品 #{productId}</h1>;
}
Catch-all ルート
// src/app/docs/[...slug]/page.tsx
// /docs/getting-started → slug = ["getting-started"]
// /docs/api/auth/login → slug = ["api", "auth", "login"]
interface Props {
params: Promise<{ slug: string[] }>;
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params;
const path = slug.join("/");
return <h1>ドキュメント: {path}</h1>;
}
Link コンポーネント — クライアントサイドナビゲーション
import Link from "next/link";
export default function Navigation() {
return (
<nav>
<Link href="/">ホーム</Link>
<Link href="/about">About</Link>
<Link href="/blog">ブログ</Link>
<Link href="/blog/hello-world">記事を読む</Link>
</nav>
);
}
<Link> は <a> タグと異なり、ページ全体のリロードなしにクライアントサイドでナビゲーションします。さらに、ビューポート内の <Link> のリンク先を自動的にプリフェッチします。
動的なリンク
interface Post {
slug: string;
title: string;
}
function PostList({ posts }: { posts: Post[] }) {
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</li>
))}
</ul>
);
}
useRouter — プログラムによるナビゲーション
"use client";
import { useRouter } from "next/navigation";
export default function LoginForm() {
const router = useRouter();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const success = await login();
if (success) {
router.push("/dashboard"); // ページ遷移
// router.replace("/dashboard"); // 履歴を残さず遷移
// router.back(); // 前のページに戻る
// router.refresh(); // 現在のページを再取得
}
}
return <form onSubmit={handleSubmit}>{/* ... */}</form>;
}
useRouter はクライアントコンポーネントでのみ使用できます。
ルートグループ — URLに影響しないフォルダ整理
フォルダ名を (name) で囲むと、URLパスに含まれません。
src/app/
├── (marketing)/
│ ├── layout.tsx ← マーケティングセクション専用レイアウト
│ ├── page.tsx ← /(トップページ)
│ └── about/
│ └── page.tsx ← /about
├── (app)/
│ ├── layout.tsx ← アプリセクション専用レイアウト
│ ├── dashboard/
│ │ └── page.tsx ← /dashboard
│ └── settings/
│ └── page.tsx ← /settings
└── layout.tsx ← ルートレイアウト
マーケティングページとアプリケーションページで異なるレイアウトを使いたい場合に有効です。
loading.tsx — ストリーミングローディング
// src/app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse">
<div className="h-8 bg-gray-200 rounded w-3/4 mb-4" />
<div className="h-4 bg-gray-200 rounded w-full mb-2" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}
loading.tsx を配置するだけで、そのルートのページ読み込み中にスケルトンUIが表示されます。内部的にはReactの <Suspense> 境界が自動的に設定されます。
error.tsx — エラーバウンダリ
// src/app/blog/error.tsx
"use client"; // error.tsx はクライアントコンポーネントである必要がある
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>エラーが発生しました</h2>
<p>{error.message}</p>
<button onClick={reset}>もう一度試す</button>
</div>
);
}
not-found.tsx — 404ページ
// src/app/not-found.tsx
import Link from "next/link";
export default function NotFound() {
return (
<div>
<h2>ページが見つかりません</h2>
<p>お探しのページは存在しないか、移動された可能性があります。</p>
<Link href="/">ホームに戻る</Link>
</div>
);
}
// 動的ルートで意図的に404を返す
import { notFound } from "next/navigation";
export default async function BlogPost({ params }: Props) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) {
notFound(); // not-found.tsx を表示
}
return <article>{post.title}</article>;
}
並行ルート(Parallel Routes)
同じレイアウト内で複数の独立したページを同時にレンダリングする機能です。ダッシュボードのような複数パネル構成に使います。
src/app/dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/
│ └── page.tsx ← 分析パネル
└── @notifications/
└── page.tsx ← 通知パネル
// src/app/dashboard/layout.tsx
export default function DashboardLayout({
children,
analytics,
notifications,
}: {
children: React.ReactNode;
analytics: React.ReactNode;
notifications: React.ReactNode;
}) {
return (
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2">{children}</div>
<div>{analytics}</div>
<div>{notifications}</div>
</div>
);
}
演習:ブログサイトのルーティング
# 課題:
# 1. 以下のルート構成を実装
# / → トップページ
# /blog → 記事一覧
# /blog/[slug] → 記事詳細(動的ルート)
# /about → Aboutページ
# 2. 共通ナビゲーションを layout.tsx に実装(Link使用)
# 3. loading.tsx でスケルトンUIを追加
# 4. not-found.tsx で404ページを実装
次回は、Next.jsにおけるデータフェッチの方法を学びます。
参考文献
- Next.js Routing: https://nextjs.org/docs/app/building-your-application/routing
- Next.js Link Component: https://nextjs.org/docs/app/api-reference/components/link
Lecture 3データフェッチ — サーバーで安全にデータを取得する
14:00
データフェッチ — サーバーで安全にデータを取得する
サーバーコンポーネントでのデータフェッチ
Next.js App Routerの革新は、Reactコンポーネント内で直接 async/await を使ってデータを取得できることです。useEffect + useState のパターンは不要になります。
// src/app/blog/page.tsx — サーバーコンポーネント(デフォルト)
interface Post {
id: number;
title: string;
body: string;
}
export default async function BlogPage() {
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=10");
const posts: Post[] = await res.json();
return (
<div>
<h1>ブログ記事一覧</h1>
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>{post.body.slice(0, 100)}...</p>
</li>
))}
</ul>
</div>
);
}
このコードはサーバーで実行されるため、APIキーやデータベース接続情報がクライアントに漏洩しません。
キャッシュとRevalidation
Next.jsの fetch は自動的にレスポンスをキャッシュします。キャッシュの振る舞いを制御する方法が3つあります。
静的データ(ビルド時に取得、デフォルト)
// デフォルトの動作 — ビルド時にフェッチされ、キャッシュされる
const res = await fetch("https://api.example.com/posts");
時間ベースのRevalidation(ISR)
// 60秒ごとにキャッシュを更新
const res = await fetch("https://api.example.com/posts", {
next: { revalidate: 60 },
});
ISR(Incremental Static Regeneration)は、静的サイト生成の速度とサーバーサイドレンダリングの鮮度を両立する手法です。
動的データ(毎回フェッチ)
// キャッシュしない — 毎回最新データを取得
const res = await fetch("https://api.example.com/posts", {
cache: "no-store",
});
ページ単位のRevalidation設定
// src/app/blog/page.tsx
export const revalidate = 3600; // このページ全体を1時間ごとに再生成
export default async function BlogPage() {
const posts = await getPosts();
return <PostList posts={posts} />;
}
データベースへの直接アクセス
サーバーコンポーネントはサーバーで実行されるため、データベースに直接接続できます。REST APIを経由する必要がありません。
// src/lib/db.ts
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY! // サーバー側なのでサービスキーを使用可能
);
// src/app/products/page.tsx
import { supabase } from "@/lib/db";
interface Product {
id: number;
name: string;
price: number;
image_url: string;
}
export default async function ProductsPage() {
const { data: products } = await supabase
.from("products")
.select("*")
.order("created_at", { ascending: false });
return (
<div className="grid grid-cols-3 gap-4">
{(products as Product[]).map((product) => (
<div key={product.id} className="border rounded p-4">
<h3>{product.name}</h3>
<p>¥{product.price.toLocaleString()}</p>
</div>
))}
</div>
);
}
環境変数 SUPABASE_SERVICE_KEY はサーバーでのみ参照可能です。NEXT_PUBLIC_ プレフィックスなしの環境変数はクライアントに公開されません。
並列データフェッチ
複数のAPIを呼ぶ場合、直列にフェッチすると遅くなります。Promise.all で並列化します。
export default async function DashboardPage() {
// 悪い例: 直列フェッチ(合計3秒)
// const users = await getUsers(); // 1秒
// const orders = await getOrders(); // 1秒
// const revenue = await getRevenue(); // 1秒
// 良い例: 並列フェッチ(合計1秒)
const [users, orders, revenue] = await Promise.all([
getUsers(),
getOrders(),
getRevenue(),
]);
return (
<div>
<h2>ユーザー数: {users.length}</h2>
<h2>注文数: {orders.length}</h2>
<h2>売上: ¥{revenue.total.toLocaleString()}</h2>
</div>
);
}
Suspenseによるストリーミング
ページ全体の読み込みを待つのではなく、準備できたコンポーネントから順にレンダリングする手法です。
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>ダッシュボード</h1>
{/* 即座に表示 */}
<Suspense fallback={<p>ユーザー読み込み中...</p>}>
<UserStats />
</Suspense>
{/* 遅いAPIでも他を待たせない */}
<Suspense fallback={<p>売上データ読み込み中...</p>}>
<RevenueChart />
</Suspense>
</div>
);
}
// 各コンポーネントは独立してデータフェッチ
async function UserStats() {
const users = await getUsers(); // 0.5秒
return <div>ユーザー数: {users.length}</div>;
}
async function RevenueChart() {
const revenue = await getRevenue(); // 3秒
return <div>月間売上: ¥{revenue.total.toLocaleString()}</div>;
}
UserStatsは0.5秒で表示され、RevenueChartは3秒後に表示されます。ユーザーは長い待ち時間なくページを使い始められます。
generateStaticParams — 静的生成
動的ルートのページをビルド時に静的HTMLとして生成する機能です。
// src/app/blog/[slug]/page.tsx
// ビルド時に生成するパスを列挙
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({
slug: post.slug,
}));
}
export default async function BlogPost({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = await getPost(slug);
return <article><h1>{post.title}</h1><p>{post.content}</p></article>;
}
100記事あれば、100個の静的HTMLがビルド時に生成されます。CDNから配信されるため、レスポンスは極めて高速です。
演習:ニュースアグリゲーター
// 課題:
// 1. JSONPlaceholder API からpostsとusersを並列フェッチ
// https://jsonplaceholder.typicode.com/posts
// https://jsonplaceholder.typicode.com/users
// 2. 記事一覧ページ(/posts)を作成
// 3. 記事詳細ページ(/posts/[id])を動的ルートで作成
// 4. Suspenseでローディング状態を管理
// 5. generateStaticParams で最初の10記事を静的生成
次回は、サーバーからデータを変更する Server Actions を学びます。
参考文献
- Next.js Data Fetching: https://nextjs.org/docs/app/building-your-application/data-fetching
- Next.js Caching: https://nextjs.org/docs/app/building-your-application/caching
Lecture 4Server Actionsとフォーム — サーバーサイドのデータ変更
14:00
Server Actionsとフォーム — サーバーサイドのデータ変更
Server Actionsとは
Server Actionsは、Next.js 14で安定版になった機能です。クライアントからサーバー上の関数を直接呼び出せます。従来のREST APIエンドポイントを作らなくても、フォーム送信やデータ更新が実装できます。
// src/app/actions.ts
"use server"; // このファイルの関数はすべてサーバーで実行される
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
// データベースに保存(サーバーで実行されるためDB直接アクセス可能)
await db.insert("posts", { title, content });
// キャッシュを再検証
revalidatePath("/blog");
}
// src/app/blog/new/page.tsx — サーバーコンポーネント
import { createPost } from "../actions";
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="タイトル" required />
<textarea name="content" placeholder="本文" required />
<button type="submit">投稿</button>
</form>
);
}
<form action={createPost}> — これだけでフォーム送信がサーバー関数に接続されます。fetch も useEffect も書く必要がありません。
バリデーション
外部からの入力は必ずサーバーサイドで検証します。
"use server";
import { z } from "zod";
import { revalidatePath } from "next/cache";
const PostSchema = z.object({
title: z.string().min(1, "タイトルは必須です").max(100, "100文字以内"),
content: z.string().min(10, "本文は10文字以上必要です"),
});
export async function createPost(formData: FormData) {
const raw = {
title: formData.get("title"),
content: formData.get("content"),
};
const result = PostSchema.safeParse(raw);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await db.insert("posts", result.data);
revalidatePath("/blog");
return { success: true };
}
useActionState — フォーム状態の管理
Server Actionの戻り値をクライアントで受け取り、エラーメッセージやローディング状態を表示するフックです。
"use client";
import { useActionState } from "react";
import { createPost } from "../actions";
interface FormState {
error?: Record<string, string[]>;
success?: boolean;
}
const initialState: FormState = {};
export default function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, initialState);
return (
<form action={formAction}>
<div>
<input name="title" placeholder="タイトル" />
{state.error?.title && (
<p className="text-red-500">{state.error.title[0]}</p>
)}
</div>
<div>
<textarea name="content" placeholder="本文" />
{state.error?.content && (
<p className="text-red-500">{state.error.content[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "送信中..." : "投稿"}
</button>
{state.success && <p className="text-green-500">投稿しました!</p>}
</form>
);
}
useOptimistic — 楽観的更新
Server Actionの完了を待たずに、UIを即座に更新する手法です。
"use client";
import { useOptimistic } from "react";
import { toggleTodo } from "../actions";
interface Todo {
id: number;
title: string;
completed: boolean;
}
export default function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, setOptimisticTodos] = useOptimistic(
todos,
(state, id: number) =>
state.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
async function handleToggle(id: number) {
setOptimisticTodos(id); // 即座にUIを更新
await toggleTodo(id); // サーバーに送信(バックグラウンド)
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id}>
<label>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span className={todo.completed ? "line-through" : ""}>
{todo.title}
</span>
</label>
</li>
))}
</ul>
);
}
チェックボックスをクリックした瞬間にUIが更新され、ユーザーは遅延を感じません。サーバー処理が失敗した場合は自動的に元の状態にロールバックされます。
revalidatePath と revalidateTag
データを変更した後、キャッシュされたページを更新する2つの方法です。
"use server";
import { revalidatePath, revalidateTag } from "next/cache";
export async function updatePost(id: number, data: FormData) {
await db.update("posts", id, { title: data.get("title") });
// 方法1: パスベースの再検証
revalidatePath("/blog"); // /blog ページのキャッシュを破棄
revalidatePath(`/blog/${id}`); // 個別記事のキャッシュも破棄
// 方法2: タグベースの再検証
revalidateTag("posts"); // "posts" タグが付いた全フェッチのキャッシュを破棄
}
// タグ付きフェッチ
const res = await fetch("https://api.example.com/posts", {
next: { tags: ["posts"] },
});
redirect — Server Actionからのリダイレクト
"use server";
import { redirect } from "next/navigation";
export async function createPost(formData: FormData) {
const post = await db.insert("posts", {
title: formData.get("title") as string,
});
redirect(`/blog/${post.slug}`);
// この行以降は実行されない
}
CRUD操作の実践例
// src/app/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
// CREATE
export async function createTodo(formData: FormData) {
const title = formData.get("title") as string;
if (!title.trim()) return { error: "タイトルは必須です" };
await db.insert("todos", { title, completed: false });
revalidatePath("/todos");
}
// UPDATE
export async function toggleTodo(id: number) {
const todo = await db.findById("todos", id);
await db.update("todos", id, { completed: !todo.completed });
revalidatePath("/todos");
}
// DELETE
export async function deleteTodo(id: number) {
await db.delete("todos", id);
revalidatePath("/todos");
}
// src/app/todos/page.tsx
import { createTodo, toggleTodo, deleteTodo } from "../actions";
export default async function TodosPage() {
const todos = await db.findAll("todos");
return (
<div>
<h1>TODOリスト</h1>
<form action={createTodo}>
<input name="title" placeholder="新しいTODO" />
<button type="submit">追加</button>
</form>
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<form action={toggleTodo.bind(null, todo.id)}>
<button type="submit">
{todo.completed ? "✓" : "○"} {todo.title}
</button>
</form>
<form action={deleteTodo.bind(null, todo.id)}>
<button type="submit">削除</button>
</form>
</li>
))}
</ul>
</div>
);
}
.bind(null, todo.id) でServer Actionに引数を渡しています。これはHTMLの <form> だけでデータの変更が完結するプログレッシブエンハンスメントのパターンです。
演習:タスク管理アプリ
// 課題:
// 1. Server Actions で CRUD 操作を実装(メモリ内配列でOK)
// 2. zodでバリデーション(タイトル必須、100文字以内)
// 3. useActionState でエラーメッセージを表示
// 4. 楽観的更新で完了/未完了の切り替えを即座に反映
// 5. revalidatePathでキャッシュを更新
次回は、スタイリングとUIコンポーネントの構築を学びます。
参考文献
- Next.js Server Actions: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations
- React useActionState: https://react.dev/reference/react/useActionState
Lecture 5スタイリングとUI — Tailwind CSSでモダンなデザイン
13:00
スタイリングとUI — Tailwind CSSでモダンなデザイン
Next.jsのスタイリング選択肢
Next.jsは複数のスタイリング方法をサポートしています。
| 方法 | 特徴 | 採用率 |
|---|---|---|
| Tailwind CSS | ユーティリティファースト、設定不要 | 最も人気 |
| CSS Modules | スコープ付きCSS、標準機能 | 堅実な選択 |
| CSS-in-JS | JSXと型の統合 | RSCでは制限あり |
| グローバルCSS | 従来のCSS | 小規模プロジェクト |
create-next-app の初期設定で Tailwind CSS を選択すると、自動的に設定が完了します。
Tailwind CSS 基礎
Tailwind CSSは「ユーティリティクラス」を組み合わせてスタイリングするCSSフレームワークです。CSSファイルを別途書く必要がありません。
export default function Card() {
return (
<div className="max-w-md mx-auto bg-white rounded-xl shadow-md overflow-hidden">
<div className="p-6">
<h2 className="text-xl font-bold text-gray-900 mb-2">
カードタイトル
</h2>
<p className="text-gray-600 leading-relaxed">
Tailwind CSSを使えば、クラス名だけでデザインが完成します。
</p>
<button className="mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
詳しく見る
</button>
</div>
</div>
);
}
よく使うユーティリティクラス
| カテゴリ | クラス例 | 効果 |
|---|---|---|
| レイアウト | flex, grid, grid-cols-3 |
Flexbox/Grid |
| スペーシング | p-4, m-2, gap-4 |
パディング/マージン |
| サイズ | w-full, h-64, max-w-lg |
幅/高さ |
| テキスト | text-xl, font-bold, text-gray-600 |
フォント |
| 背景 | bg-white, bg-blue-600 |
背景色 |
| 角丸 | rounded, rounded-xl |
ボーダー半径 |
| シャドウ | shadow-sm, shadow-md, shadow-lg |
ドロップシャドウ |
| レスポンシブ | md:grid-cols-2, lg:text-xl |
ブレークポイント |
レスポンシブデザイン
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* モバイル: 1列、タブレット: 2列、デスクトップ: 3列 */}
{products.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
Tailwindのブレークポイントはモバイルファーストです。md: は768px以上、lg: は1024px以上で適用されます。
ダークモード
<div className="bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<h1 className="text-2xl font-bold">ダークモード対応</h1>
</div>
tailwind.config.ts で darkMode: "class" を設定すると、<html class="dark"> の有無でダークモードが切り替わります。
CSS Modules
コンポーネント固有のスタイルをスコープ付きで定義します。
/* src/app/components/Button.module.css */
.button {
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-weight: 600;
transition: background-color 0.2s;
}
.primary {
background-color: #2563eb;
color: white;
}
.primary:hover {
background-color: #1d4ed8;
}
// src/app/components/Button.tsx
import styles from "./Button.module.css";
interface ButtonProps {
children: React.ReactNode;
variant?: "primary" | "secondary";
}
export default function Button({ children, variant = "primary" }: ButtonProps) {
return (
<button className={`${styles.button} ${styles[variant]}`}>
{children}
</button>
);
}
CSS Modulesのクラス名は自動的にユニーク化(例: Button_primary_a1b2c)されるため、名前の衝突が起きません。
next/font — Webフォントの最適化
// src/app/layout.tsx
import { Noto_Sans_JP } from "next/font/google";
const notoSansJP = Noto_Sans_JP({
subsets: ["latin"],
weight: ["400", "700"],
display: "swap",
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja" className={notoSansJP.className}>
<body>{children}</body>
</html>
);
}
next/font はビルド時にフォントファイルをダウンロードし、セルフホスティングします。Google Fontsへの外部リクエストが不要になり、CLS(Cumulative Layout Shift)も防げます。
next/image — 画像の最適化
import Image from "next/image";
export default function ProductCard({ product }: { product: Product }) {
return (
<div className="border rounded-lg overflow-hidden">
<Image
src={product.imageUrl}
alt={product.name}
width={400}
height={300}
className="object-cover"
priority={false} // true にするとLCP画像として優先読み込み
/>
<div className="p-4">
<h3 className="font-bold">{product.name}</h3>
<p>¥{product.price.toLocaleString()}</p>
</div>
</div>
);
}
next/image が自動で行う最適化:
- WebP/AVIF への変換(ブラウザ対応に応じて)
- リサイズ(デバイスの画面サイズに最適化)
- 遅延読み込み(ビューポートに近づいたら読み込み)
- CLS防止(width/height で事前にスペースを確保)
コンポーネントライブラリの活用
ゼロからUIを作る必要はありません。
# shadcn/ui — コピー&ペースト型のコンポーネント集
npx shadcn@latest init
npx shadcn@latest add button card dialog
import { Button } from "@/components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
export default function Dashboard() {
return (
<Card>
<CardHeader>
<CardTitle>売上概要</CardTitle>
</CardHeader>
<CardContent>
<p>今月の売上: ¥1,234,567</p>
<Button variant="outline">詳細を見る</Button>
</CardContent>
</Card>
);
}
shadcn/uiはnode_modulesではなくプロジェクト内にコードをコピーするため、完全にカスタマイズ可能です。Tailwind CSSとの親和性が高く、Next.jsプロジェクトでの採用率が急速に伸びています。
演習:商品一覧ページのデザイン
// 課題:
// 1. Tailwind CSSで商品カードコンポーネントを作成
// - 画像(next/image)、商品名、価格、カテゴリバッジ
// 2. レスポンシブグリッド(モバイル1列、タブレット2列、PC3列)
// 3. ダークモード対応
// 4. ホバーエフェクト(scale + shadow)
// 5. Noto Sans JPフォントを適用
次回から後半のモジュールに入り、Route HandlersとAPIの構築を学びます。
参考文献
- Tailwind CSS: https://tailwindcss.com/docs
- Next.js Image Optimization: https://nextjs.org/docs/app/building-your-application/optimizing/images
- shadcn/ui: https://ui.shadcn.com/
Lecture 6Route HandlersとAPI — バックエンドをNext.jsに統合する
13:00
Route HandlersとAPI — バックエンドをNext.jsに統合する
Route Handlers とは
Route Handlersは、Next.js内でREST APIエンドポイントを作成する機能です。app ディレクトリ内に route.ts ファイルを配置するだけでAPIが完成します。
src/app/api/
├── users/
│ ├── route.ts ← GET /api/users, POST /api/users
│ └── [id]/
│ └── route.ts ← GET /api/users/123, PUT, DELETE
└── health/
└── route.ts ← GET /api/health
基本的なRoute Handler
// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
// メモリ内データストア(実際にはDBを使用)
const users = [
{ id: 1, name: "太郎", email: "taro@example.com" },
{ id: 2, name: "花子", email: "hanako@example.com" },
];
// GET /api/users
export async function GET() {
return NextResponse.json(users);
}
// POST /api/users
export async function POST(request: NextRequest) {
const body = await request.json();
const newUser = {
id: users.length + 1,
name: body.name,
email: body.email,
};
users.push(newUser);
return NextResponse.json(newUser, { status: 201 });
}
HTTP メソッド名をそのまま関数名にします。GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS がサポートされています。
動的ルートのRoute Handler
// src/app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
interface Props {
params: Promise<{ id: string }>;
}
// GET /api/users/123
export async function GET(request: NextRequest, { params }: Props) {
const { id } = await params;
const user = users.find((u) => u.id === parseInt(id));
if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}
return NextResponse.json(user);
}
// PUT /api/users/123
export async function PUT(request: NextRequest, { params }: Props) {
const { id } = await params;
const body = await request.json();
const index = users.findIndex((u) => u.id === parseInt(id));
if (index === -1) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
users[index] = { ...users[index], ...body };
return NextResponse.json(users[index]);
}
// DELETE /api/users/123
export async function DELETE(request: NextRequest, { params }: Props) {
const { id } = await params;
const index = users.findIndex((u) => u.id === parseInt(id));
if (index === -1) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
}
users.splice(index, 1);
return new NextResponse(null, { status: 204 });
}
リクエストの処理
クエリパラメータ
// GET /api/users?page=2&limit=10&sort=name
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = parseInt(searchParams.get("page") ?? "1");
const limit = parseInt(searchParams.get("limit") ?? "20");
const sort = searchParams.get("sort") ?? "id";
const sorted = [...users].sort((a, b) =>
String(a[sort as keyof typeof a]).localeCompare(String(b[sort as keyof typeof b]))
);
const paginated = sorted.slice((page - 1) * limit, page * limit);
return NextResponse.json({
data: paginated,
total: users.length,
page,
limit,
});
}
ヘッダーの操作
export async function GET(request: NextRequest) {
// リクエストヘッダーの読み取り
const authHeader = request.headers.get("authorization");
const contentType = request.headers.get("content-type");
// レスポンスヘッダーの設定
return NextResponse.json(
{ data: "protected content" },
{
headers: {
"Cache-Control": "no-store",
"X-Custom-Header": "value",
},
}
);
}
Cookieの操作
import { cookies } from "next/headers";
export async function GET() {
const cookieStore = await cookies();
const token = cookieStore.get("session-token");
return NextResponse.json({ authenticated: !!token });
}
export async function POST(request: NextRequest) {
const body = await request.json();
const response = NextResponse.json({ success: true });
response.cookies.set("session-token", body.token, {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 1週間
});
return response;
}
バリデーション with zod
import { z } from "zod";
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).optional(),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return NextResponse.json(
{ errors: result.error.flatten().fieldErrors },
{ status: 400 }
);
}
const user = { id: Date.now(), ...result.data };
return NextResponse.json(user, { status: 201 });
}
Server Actions vs Route Handlers
| 観点 | Server Actions | Route Handlers |
|---|---|---|
| 用途 | フォーム送信、データ変更 | REST API、Webhook |
| 呼び出し元 | Next.jsのフォーム/コンポーネント | 任意のHTTPクライアント |
| プロトコル | RPC風(関数呼び出し) | REST(HTTP メソッド) |
| 外部からのアクセス | 不可(内部専用) | 可能(公開API) |
| キャッシュ制御 | revalidatePath/Tag | レスポンスヘッダー |
使い分け: Next.jsアプリ内のデータ変更は Server Actions、モバイルアプリや外部サービスとの連携には Route Handlers を使います。
CORS設定
外部ドメインからのアクセスを許可する場合:
export async function GET(request: NextRequest) {
const data = { message: "Hello from API" };
return NextResponse.json(data, {
headers: {
"Access-Control-Allow-Origin": "https://example.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
export async function OPTIONS() {
return new NextResponse(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "https://example.com",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
},
});
}
演習:ブックマークAPI
// 課題:
// 1. Route Handlers で CRUD API を実装
// GET /api/bookmarks → 一覧(ページネーション付き)
// POST /api/bookmarks → 作成(zodバリデーション)
// GET /api/bookmarks/[id] → 詳細
// PUT /api/bookmarks/[id] → 更新
// DELETE /api/bookmarks/[id] → 削除
// 2. クエリパラメータで検索とソートを実装
// 3. エラーレスポンスを統一フォーマットで返す
次回は、認証とセキュリティの実装を学びます。
参考文献
- Next.js Route Handlers: https://nextjs.org/docs/app/building-your-application/routing/route-handlers
- Next.js API Reference (NextRequest/NextResponse): https://nextjs.org/docs/app/api-reference/functions/next-request
Lecture 7認証とミドルウェア — セキュアなアプリケーションを構築する
14:00
認証とミドルウェア — セキュアなアプリケーションを構築する
Web認証の基礎
認証(Authentication)は「あなたは誰か」を確認するプロセスです。認可(Authorization)は「あなたは何ができるか」を判断するプロセスです。Next.jsアプリケーションでは、この2つを正しく実装する必要があります。
| 方式 | 仕組み | 主な用途 |
|---|---|---|
| セッションベース | サーバーにセッションを保存、CookieでIDを送信 | 従来のWebアプリ |
| JWTベース | トークンをクライアントに保存、リクエストに添付 | SPA、モバイルAPI |
| OAuth | Google等の外部サービスで認証を代行 | ソーシャルログイン |
NextAuth.js(Auth.js)による認証
NextAuth.js(v5からAuth.jsに改名)は、Next.js専用の認証ライブラリです。
npm install next-auth@beta
// src/auth.ts
import NextAuth from "next-auth";
import GitHub from "next-auth/providers/github";
import Google from "next-auth/providers/google";
import Credentials from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
GitHub({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
}),
Google({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
// データベースでユーザーを検証
const user = await findUserByEmail(credentials.email as string);
if (user && await verifyPassword(credentials.password as string, user.passwordHash)) {
return { id: String(user.id), name: user.name, email: user.email };
}
return null;
},
}),
],
pages: {
signIn: "/login", // カスタムログインページ
},
});
// src/app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
サーバーコンポーネントでのセッション確認
// src/app/dashboard/page.tsx
import { auth } from "@/auth";
import { redirect } from "next/navigation";
export default async function DashboardPage() {
const session = await auth();
if (!session) {
redirect("/login");
}
return (
<div>
<h1>ダッシュボード</h1>
<p>こんにちは、{session.user?.name}さん</p>
<img src={session.user?.image ?? ""} alt="アバター" width={48} height={48} />
</div>
);
}
クライアントコンポーネントでのセッション
"use client";
import { useSession, signIn, signOut } from "next-auth/react";
export default function AuthButton() {
const { data: session, status } = useSession();
if (status === "loading") return <p>読み込み中...</p>;
if (session) {
return (
<div>
<p>{session.user?.name}</p>
<button onClick={() => signOut()}>ログアウト</button>
</div>
);
}
return (
<div>
<button onClick={() => signIn("github")}>GitHubでログイン</button>
<button onClick={() => signIn("google")}>Googleでログイン</button>
</div>
);
}
ミドルウェア
ミドルウェアは、リクエストがページやAPIに到達する前に実行される処理です。認証チェック、リダイレクト、ヘッダー操作などに使います。
// src/middleware.ts — プロジェクトルートに配置
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// /dashboard/* へのアクセスに認証を要求
if (pathname.startsWith("/dashboard")) {
const token = request.cookies.get("session-token");
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
}
// レスポンスヘッダーの追加
const response = NextResponse.next();
response.headers.set("X-Request-Id", crypto.randomUUID());
return response;
}
// ミドルウェアを適用するパスを指定
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};
NextAuth.js のミドルウェア統合
// src/middleware.ts
export { auth as middleware } from "@/auth";
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
これだけで、指定したパスに未認証でアクセスすると自動的にログインページにリダイレクトされます。
保護されたRoute Handler
// src/app/api/protected/route.ts
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
return NextResponse.json({
message: `Hello, ${session.user?.name}`,
userId: session.user?.id,
});
}
保護されたServer Action
"use server";
import { auth } from "@/auth";
export async function createPost(formData: FormData) {
const session = await auth();
if (!session?.user?.id) {
throw new Error("Unauthorized");
}
const title = formData.get("title") as string;
await db.insert("posts", {
title,
authorId: session.user.id,
});
}
ロールベースのアクセス制御
// src/lib/auth-utils.ts
import { auth } from "@/auth";
import { redirect } from "next/navigation";
type Role = "user" | "admin" | "editor";
export async function requireAuth() {
const session = await auth();
if (!session) redirect("/login");
return session;
}
export async function requireRole(role: Role) {
const session = await requireAuth();
if (session.user?.role !== role) {
redirect("/unauthorized");
}
return session;
}
// src/app/admin/page.tsx
import { requireRole } from "@/lib/auth-utils";
export default async function AdminPage() {
const session = await requireRole("admin");
return <h1>管理画面 — {session.user?.name}</h1>;
}
環境変数のセキュリティ
# .env.local(Gitにコミットしない)
GITHUB_CLIENT_ID=xxx
GITHUB_CLIENT_SECRET=yyy
NEXTAUTH_SECRET=your-random-secret-here
NEXTAUTH_URL=http://localhost:3000
# NEXT_PUBLIC_ プレフィックス付きはクライアントに公開される
NEXT_PUBLIC_APP_URL=https://example.com
ルール: シークレットキー、データベースURL、APIキーには NEXT_PUBLIC_ プレフィックスを付けてはいけません。サーバーコンポーネント、Server Action、Route Handler でのみ参照します。
演習:認証付きアプリ
# 課題:
# 1. NextAuth.js をセットアップ(GitHub OAuth)
# 2. /login ページにログインボタンを配置
# 3. /dashboard ページを認証必須にする(未認証→リダイレクト)
# 4. ミドルウェアで /dashboard/* を保護
# 5. ナビゲーションにユーザー名とログアウトボタンを表示
次回は、データベース連携とORMの使い方を学びます。
参考文献
- NextAuth.js (Auth.js): https://authjs.dev/
- Next.js Middleware: https://nextjs.org/docs/app/building-your-application/routing/middleware
- Next.js Authentication: https://nextjs.org/docs/app/building-your-application/authentication
Lecture 8データベース連携 — PrismaとSupabaseで永続化する
14:00
データベース連携 — PrismaとSupabaseで永続化する
Next.jsとデータベース
サーバーコンポーネントとServer Actionsは、データベースに直接アクセスできます。別途APIサーバーを立てる必要がないのがNext.jsの大きな利点です。
クライアント(ブラウザ)
↕ HTTP
Next.js サーバー(Server Component / Server Action / Route Handler)
↕ SQL / ORM
データベース(PostgreSQL / MySQL / SQLite)
Prisma — TypeScript向けORM
Prismaは、TypeScriptとの親和性が最も高いORM(Object-Relational Mapping)です。スキーマ定義から型安全なクライアントを自動生成します。
セットアップ
npm install prisma @prisma/client
npx prisma init --datasource-provider postgresql
スキーマ定義
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
tags Tag[]
createdAt DateTime @default(now())
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[]
}
# マイグレーション実行
npx prisma migrate dev --name init
# Prisma Client を生成
npx prisma generate
型安全なデータベース操作
// src/lib/prisma.ts
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
// 開発時のホットリロードで接続が増殖するのを防止
// サーバーコンポーネントでの使用
import { prisma } from "@/lib/prisma";
export default async function PostsPage() {
// Prismaの戻り値は自動的に型付けされる
const posts = await prisma.post.findMany({
where: { published: true },
include: { author: true, tags: true },
orderBy: { createdAt: "desc" },
take: 20,
});
// posts の型: (Post & { author: User; tags: Tag[] })[]
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{post.title}</h2>
<p>by {post.author.name}</p>
{post.tags.map((tag) => (
<span key={tag.id} className="tag">{tag.name}</span>
))}
</li>
))}
</ul>
);
}
Server ActionsでのCRUD
"use server";
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
import { z } from "zod";
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).optional(),
});
export async function createPost(formData: FormData) {
const raw = {
title: formData.get("title"),
content: formData.get("content"),
tags: formData.getAll("tags"),
};
const result = PostSchema.safeParse(raw);
if (!result.success) {
return { error: result.error.flatten().fieldErrors };
}
await prisma.post.create({
data: {
title: result.data.title,
content: result.data.content,
authorId: 1, // 実際には認証から取得
tags: {
connectOrCreate: (result.data.tags ?? []).map((name) => ({
where: { name },
create: { name },
})),
},
},
});
revalidatePath("/posts");
return { success: true };
}
Supabaseとの連携
Supabaseは「オープンソースのFirebase代替」で、PostgreSQL + 認証 + ストレージ + リアルタイムを統合したBaaSです。
npm install @supabase/supabase-js
// src/lib/supabase/server.ts — サーバーサイド用
import { createClient } from "@supabase/supabase-js";
export function createServerClient() {
return createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!
);
}
// src/lib/supabase/client.ts — クライアントサイド用
import { createClient } from "@supabase/supabase-js";
export const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
Supabaseでのデータフェッチ
// サーバーコンポーネント
import { createServerClient } from "@/lib/supabase/server";
export default async function ProductsPage() {
const supabase = createServerClient();
const { data: products, error } = await supabase
.from("products")
.select("*, categories(name)")
.order("created_at", { ascending: false })
.limit(20);
if (error) throw new Error(error.message);
return (
<div className="grid grid-cols-3 gap-4">
{products.map((product) => (
<div key={product.id} className="border rounded p-4">
<h3>{product.name}</h3>
<p>¥{product.price.toLocaleString()}</p>
<span className="text-sm text-gray-500">
{product.categories?.name}
</span>
</div>
))}
</div>
);
}
型の自動生成
npx supabase gen types typescript --project-id your-project-id > src/types/database.ts
import { createClient } from "@supabase/supabase-js";
import type { Database } from "@/types/database";
const supabase = createClient<Database>(url, key);
// これで全テーブル・全カラムの型チェックが効く
Prisma vs Supabase Client
| 観点 | Prisma | Supabase Client |
|---|---|---|
| 型安全性 | スキーマから自動生成(最強) | CLI型生成(良好) |
| 学習コスト | やや高い(スキーマ言語) | 低い(SQL風API) |
| マイグレーション | Prisma Migrate(組み込み) | Supabase Migration(SQL) |
| リアルタイム | 対応なし | Realtime(WebSocket) |
| 認証 | 別途実装 | Supabase Auth統合 |
| 対応DB | PostgreSQL, MySQL, SQLite等 | PostgreSQLのみ |
演習:ブログアプリのデータ永続化
# 課題:
# 1. Prisma をセットアップし、User/Post/Tag モデルを定義
# 2. Server Actions で記事の CRUD を実装
# 3. 記事一覧ページ(/posts)で findMany + include を使用
# 4. 記事詳細ページ(/posts/[id])で findUnique を使用
# 5. zodバリデーション + エラーハンドリング
次回は、テストと品質管理の手法を学びます。
参考文献
- Prisma公式ドキュメント: https://www.prisma.io/docs
- Supabase + Next.js: https://supabase.com/docs/guides/getting-started/quickstarts/nextjs
Lecture 9テストとパフォーマンス — 品質と速度を両立する
13:00
テストとパフォーマンス — 品質と速度を両立する
Next.jsアプリのテスト戦略
テストには3つのレベルがあります。
| レベル | 対象 | ツール | 速度 |
|---|---|---|---|
| ユニットテスト | 関数、ユーティリティ | Vitest | 最速 |
| コンポーネントテスト | UIコンポーネント | Testing Library | 速い |
| E2Eテスト | ユーザーフロー全体 | Playwright | 遅い |
Vitest — ユニットテスト
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom
// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./src/test/setup.ts"],
},
resolve: {
alias: { "@": resolve(__dirname, "src") },
},
});
// src/lib/utils.test.ts
import { describe, it, expect } from "vitest";
import { formatCurrency, truncate, slugify } from "./utils";
describe("formatCurrency", () => {
it("数値を日本円フォーマットに変換する", () => {
expect(formatCurrency(1980)).toBe("¥1,980");
expect(formatCurrency(0)).toBe("¥0");
expect(formatCurrency(1000000)).toBe("¥1,000,000");
});
});
describe("truncate", () => {
it("指定文字数で切り詰めて...を付加する", () => {
expect(truncate("Hello, World!", 5)).toBe("Hello...");
expect(truncate("Hi", 10)).toBe("Hi");
});
});
describe("slugify", () => {
it("タイトルをURL用のスラッグに変換する", () => {
expect(slugify("Hello World")).toBe("hello-world");
expect(slugify("TypeScript入門")).toBe("typescript入門");
});
});
Testing Library — コンポーネントテスト
// src/components/Counter.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import Counter from "./Counter";
describe("Counter", () => {
it("初期値0を表示する", () => {
render(<Counter />);
expect(screen.getByText("クリック数: 0")).toBeDefined();
});
it("ボタンクリックでカウントが増える", () => {
render(<Counter />);
const button = screen.getByRole("button");
fireEvent.click(button);
expect(screen.getByText("クリック数: 1")).toBeDefined();
fireEvent.click(button);
expect(screen.getByText("クリック数: 2")).toBeDefined();
});
});
フォームコンポーネントのテスト
// src/components/SearchForm.test.tsx
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import SearchForm from "./SearchForm";
describe("SearchForm", () => {
it("入力値でonSearchが呼ばれる", () => {
const mockSearch = vi.fn();
render(<SearchForm onSearch={mockSearch} />);
const input = screen.getByPlaceholderText("検索...");
fireEvent.change(input, { target: { value: "TypeScript" } });
const button = screen.getByRole("button", { name: "検索" });
fireEvent.click(button);
expect(mockSearch).toHaveBeenCalledWith("TypeScript");
});
it("空の入力では送信しない", () => {
const mockSearch = vi.fn();
render(<SearchForm onSearch={mockSearch} />);
const button = screen.getByRole("button", { name: "検索" });
fireEvent.click(button);
expect(mockSearch).not.toHaveBeenCalled();
});
});
Playwright — E2Eテスト
npm install -D @playwright/test
npx playwright install
// e2e/blog.spec.ts
import { test, expect } from "@playwright/test";
test.describe("ブログ機能", () => {
test("記事一覧が表示される", async ({ page }) => {
await page.goto("/blog");
await expect(page.getByRole("heading", { name: "ブログ" })).toBeVisible();
const articles = page.getByRole("article");
await expect(articles).toHaveCount(10); // 10件表示
});
test("記事詳細に遷移できる", async ({ page }) => {
await page.goto("/blog");
await page.getByText("最初の記事").click();
await expect(page).toHaveURL(/\/blog\/.+/);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
});
test("新規記事を投稿できる", async ({ page }) => {
await page.goto("/blog/new");
await page.getByLabel("タイトル").fill("テスト記事");
await page.getByLabel("本文").fill("これはテスト用の記事です。");
await page.getByRole("button", { name: "投稿" }).click();
await expect(page).toHaveURL(/\/blog\/.+/);
await expect(page.getByText("テスト記事")).toBeVisible();
});
});
パフォーマンス最適化
動的インポート — 必要な時にだけ読み込む
import dynamic from "next/dynamic";
// ChartComponent は初回レンダリングに不要 → 遅延読み込み
const Chart = dynamic(() => import("@/components/Chart"), {
loading: () => <p>グラフを読み込み中...</p>,
ssr: false, // クライアントのみでレンダリング
});
export default function DashboardPage() {
return (
<div>
<h1>ダッシュボード</h1>
<Chart data={chartData} />
</div>
);
}
React.memo — 不要な再レンダリングを防ぐ
import { memo } from "react";
interface ProductCardProps {
name: string;
price: number;
imageUrl: string;
}
const ProductCard = memo(function ProductCard({ name, price, imageUrl }: ProductCardProps) {
return (
<div className="border rounded p-4">
<img src={imageUrl} alt={name} />
<h3>{name}</h3>
<p>¥{price.toLocaleString()}</p>
</div>
);
});
メタデータの最適化
// src/app/blog/[slug]/page.tsx
import type { Metadata } from "next";
// 動的メタデータ
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: `${post.title} | My Blog`,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage }],
},
twitter: {
card: "summary_large_image",
},
};
}
Core Web Vitals の計測
// src/app/layout.tsx
import { SpeedInsights } from "@vercel/speed-insights/next";
import { Analytics } from "@vercel/analytics/next";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ja">
<body>
{children}
<SpeedInsights />
<Analytics />
</body>
</html>
);
}
| 指標 | 目標 | 意味 |
|---|---|---|
| LCP(Largest Contentful Paint) | < 2.5秒 | 最大要素の表示速度 |
| FID(First Input Delay) | < 100ms | 初回入力の応答速度 |
| CLS(Cumulative Layout Shift) | < 0.1 | レイアウトのずれ |
演習:テストを書く
// 課題:
// 1. ユーティリティ関数のユニットテストを3つ以上書く
// 2. Counterコンポーネントのテストを書く
// 3. npm run test で全テストがパスすることを確認
// 4. Lighthouseでパフォーマンススコアを計測し、改善点を特定
次回の最終講義では、本番デプロイとプロジェクトの総まとめを行います。
参考文献
- Next.js Testing: https://nextjs.org/docs/app/building-your-application/testing
- Vitest: https://vitest.dev/
- Playwright: https://playwright.dev/
- Web Vitals: https://web.dev/vitals/
Lecture 10デプロイと総まとめ — 作ったアプリを世界に公開する
14:00
デプロイと総まとめ — 作ったアプリを世界に公開する
デプロイの選択肢
Next.jsアプリケーションのデプロイ先には複数の選択肢があります。
| プラットフォーム | 特徴 | 料金 |
|---|---|---|
| Vercel | Next.js開発元、ゼロ設定 | 無料枠あり |
| Cloudflare Pages | エッジ実行、高速 | 無料枠あり |
| AWS Amplify | AWSエコシステム統合 | 従量課金 |
| Docker + VPS | 完全制御 | 月額数百円〜 |
Vercelへのデプロイ
Vercelは Next.js を開発している会社のホスティングサービスです。最も簡単にデプロイできます。
手順
# 1. GitHubリポジトリにプッシュ
git init
git add .
git commit -m "Initial commit"
git remote add origin https://github.com/username/my-app.git
git push -u origin main
# 2. Vercelにサインアップ(https://vercel.com)
# 3. "Import Project" → GitHubリポジトリを選択
# 4. 環境変数を設定(DATABASE_URL, NEXTAUTH_SECRET等)
# 5. "Deploy" をクリック
以降、main ブランチへのプッシュで自動デプロイされます。プルリクエストごとにプレビューURLが自動生成されるのも大きな利点です。
環境変数の設定
# Vercel CLIでの設定
npm i -g vercel
vercel env add DATABASE_URL production
vercel env add NEXTAUTH_SECRET production
Vercelダッシュボードの Settings → Environment Variables からも設定できます。NEXT_PUBLIC_ プレフィックス付きの変数はビルド時に埋め込まれるため、変更にはリビルドが必要です。
Dockerによるセルフホスティング
# Dockerfile
FROM node:20-alpine AS base
FROM base AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
CMD ["node", "server.js"]
// next.config.ts — standalone出力を有効化
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
# ビルドと実行
docker build -t my-nextjs-app .
docker run -p 3000:3000 --env-file .env.production my-nextjs-app
静的エクスポート
サーバー機能が不要な場合、完全な静的サイトとしてエクスポートできます。
// next.config.ts
const nextConfig: NextConfig = {
output: "export",
};
npm run build
# out/ ディレクトリに静的HTMLが生成される
# 任意のWebサーバー(Nginx, S3, Cloudflare Pages)でホスティング可能
制約: Server Components、Server Actions、Route Handlers、ミドルウェア、画像最適化は使用不可です。ブログやドキュメントサイトに適しています。
CI/CD パイプライン
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npm run lint
- run: npm run test
- run: npm run build
e2e:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: "npm"
- run: npm ci
- run: npx playwright install --with-deps
- run: npm run build
- run: npx playwright test
プロジェクト構成のベストプラクティス
src/
├── app/ ← ルーティング(ページ + レイアウト)
│ ├── (marketing)/ ← マーケティングページ群
│ ├── (app)/ ← アプリケーションページ群
│ ├── api/ ← Route Handlers
│ ├── layout.tsx
│ └── page.tsx
├── components/ ← UIコンポーネント
│ ├── ui/ ← 汎用UI(Button, Card等)
│ └── features/ ← 機能固有コンポーネント
├── lib/ ← ユーティリティ、DB接続
│ ├── prisma.ts
│ ├── supabase/
│ └── utils.ts
├── actions/ ← Server Actions
├── types/ ← 型定義
└── test/ ← テストセットアップ
本番チェックリスト
| カテゴリ | チェック項目 |
|---|---|
| セキュリティ | 環境変数にシークレットが含まれていないか |
| セキュリティ | CSRF対策(Server Actionsは自動対応) |
| セキュリティ | Content Security Policy ヘッダー |
| パフォーマンス | Lighthouse スコア 90+ |
| パフォーマンス | next/image で画像最適化 |
| パフォーマンス | 動的インポートで初回バンドル削減 |
| SEO | 全ページにメタデータ設定 |
| SEO | sitemap.xml と robots.txt |
| SEO | OGP画像の設定 |
| 監視 | エラートラッキング(Sentry等) |
| 監視 | パフォーマンスモニタリング |
sitemap.xml と robots.txt
// src/app/sitemap.ts
import type { MetadataRoute } from "next";
export default async function sitemap(): MetadataRoute.Sitemap {
const posts = await getAllPosts();
const postUrls = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "weekly" as const,
priority: 0.8,
}));
return [
{ url: "https://example.com", lastModified: new Date(), priority: 1 },
{ url: "https://example.com/about", lastModified: new Date(), priority: 0.5 },
...postUrls,
];
}
// src/app/robots.ts
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/", disallow: "/api/" },
sitemap: "https://example.com/sitemap.xml",
};
}
このコースの学習ロードマップ
| 講義 | 習得した技術 |
|---|---|
| 第1講 | Next.jsの全体像、App Router、RSC |
| 第2講 | ファイルベースルーティング、動的ルート、Link |
| 第3講 | サーバーデータフェッチ、キャッシュ、Suspense |
| 第4講 | Server Actions、フォーム、楽観的更新 |
| 第5講 | Tailwind CSS、next/image、next/font |
| 第6講 | Route Handlers、REST API構築 |
| 第7講 | NextAuth.js、ミドルウェア、認証 |
| 第8講 | Prisma、Supabase、データベース連携 |
| 第9講 | Vitest、Playwright、パフォーマンス最適化 |
| 第10講 | Vercelデプロイ、Docker、CI/CD |
次のステップ
| 方向 | 学ぶべきこと |
|---|---|
| フルスタック深掘り | T3 Stack(tRPC + Prisma + NextAuth) |
| リアルタイム | Supabase Realtime / Socket.io |
| CMS連携 | Contentful / Sanity / microCMS |
| モバイル | React Native / Expo |
| インフラ | AWS / Terraform / Kubernetes |
Next.jsは「Reactを知っていれば、フルスタックアプリが作れる」世界を実現しました。このコースで学んだ知識を土台に、実際のプロジェクトを作って公開してください。
参考文献
- Next.js Deploying: https://nextjs.org/docs/app/building-your-application/deploying
- Vercel Documentation: https://vercel.com/docs
- Next.js GitHub Examples: https://github.com/vercel/next.js/tree/canary/examples