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位を記録しています。

ReactUIライブラリ)
  └── 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>;
}
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}> — これだけでフォーム送信がサーバー関数に接続されます。fetchuseEffect も書く必要がありません。

バリデーション

外部からの入力は必ずサーバーサイドで検証します。

"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.tsdarkMode: "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",
      },
    }
  );
}
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/                 汎用UIButton, 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