📚

【個人開発】Next.js 16 × Server Actionsだけで読書ガイドPWAを作り切った設計と実装

に公開

TL;DR

読書中に「次は何を考えればいいか」をガイドするPWAを、Next.js 16のServer Actionsだけで作り切りました。

本の詳細

  • 🔗 アプリ: 実り読み(ReadHarvest)
  • 📝 データ変更はすべてServer Actions(Route Handlerは外部API連携のみ)
  • 🌍 next-intl v4で日英両対応
  • 📱 PWA対応。ホーム画面に追加してネイティブ風に使えます

この記事では、技術選定の理由と設計判断で迷ったポイントを共有します。

🤔 何を作ったか

ノンフィクションの読書を5つのフェーズでガイドするアプリです。

ノンフィクションの読書法について調べる中でアクティブリーディングの手法を知り、効果を実感しました。ただ、付箋やノートでやるのは隙間時間に合わない。「読書中にガイドしてくれるアプリ」を探したものの見つからなかったので、自分なりにアレンジした構成でアプリに落とし込みました。

5フェーズの設計

# フェーズ 単位 やること
1 予測(Predict) 本全体 読む前に仮説を立てる
2 対話(Question) 章ごと 疑問を持って読む
3 要約(Summarize) 章ごと 一言でまとめる
4 検証(Compare) 章ごと 著者の主張に自分の意見を持つ
5 自分化(Harvest) 本全体 最初の予測と比較して振り返る

Phase 2〜4を章単位で繰り返し、最後にPhase 5で本全体を振り返る構造になっています。

🛠 技術スタック

技術 選定理由
Next.js 16 (App Router) RSC + Server Actionsでフルスタック完結。Turbopackで開発サーバーも高速
React 19 Server Components・Server Actionsの安定版
TypeScript (strict) noUncheckedIndexedAccessまで有効化。配列アクセスの安全性を担保
Supabase DB + 認証。個人開発のスピード重視
TanStack Query v5 Server Component → Client Componentへのデータ橋渡しとキャッシュ管理
Tailwind CSS 4 v4のCSS-first configで設定がシンプルに
shadcn/ui UIコンポーネント。必要なものだけインストールできる
next-intl v4 App Router対応のi18n。日英両対応
Vercel Next.jsとの相性。プレビューデプロイが便利
Vercel Analytics + Speed Insights 軽量なPageview計測 + Core Web Vitals計測
Sentry エラートラッキング。本番障害の早期検知
Vitest + Testing Library テスティングトロフィーに基づくテスト戦略
pnpm 高速なパッケージ管理

すべて無料枠で運用中(ドメイン代以外)

🏗 アーキテクチャ

ディレクトリ構成

features/ディレクトリで機能ごとにまとめる構成を採用しました。


src/
├── app/ # ルーティングのみ(薄く保つ)
│ └── [locale]/
├── features/ # 機能ごとにまとめる
│ ├── books/ # 本の管理
│ │ ├── actions/ # Server Actions
│ │ ├── hooks/ # カスタムフック
│ │ └── \*.tsx # UIコンポーネント
│ ├── chapters/ # 章の管理
│ ├── phases/ # フェーズの管理
│ ├── notes/ # ノートの管理
│ ├── lp/ # ランディングページ
│ ├── auth/ # 認証
│ └── onboarding/ # オンボーディング
├── components/ui/ # shadcn/ui(共通部品)
└── lib/
├── supabase/ # client, server, types
├── tanstack/ # TanStack Query設定
└── constants/ # プロンプト定義等

ポイントはfeature固有のコンポーネント・Server Actions・フックはすべてそのfeature内に配置していること。components/ディレクトリは shadcn/ui の共通部品だけに留めています。

DB設計

工夫した点はこのあたりです:

  • 章は都度追加方式。最初に全章を登録する必要がありません(何章あるか分からない本もありますよね)
  • Phase 1(予測)と5(自分化)は本全体に紐づくフェーズ。章単位のPhase 2〜4とは別に管理しています

💡 設計判断で迷ったポイント

1. プロンプトキーをDBに保存する設計

各フェーズに3〜5つのガイドプロンプト(「この本から何を学びたいですか?」のような問いかけ)があります。

このプロンプトのテキストをどこに持つかは結構迷いました。

結論として、DBにはi18nキー(文字列)を保存し、テキスト本体はmessages/*.jsonで管理する方式にしています。

// src/lib/constants/prompts.ts
// notes.prompt カラムに保存されるキー文字列
export type PromptKey =
  | 'phase1.prediction'
  | 'phase1.goal'
  | 'phase1.path'
  | 'phase1.starting_point'
  | 'phase2.qa'
  | 'phase2.pre_questions'
  | 'phase2.connection'
  | 'phase3.summary'
  | 'phase3.argument'
  | 'phase3.resonance'
  | 'phase3.application'
  | 'phase4.comparison'
  // ... 全20種

// 各フェーズのプロンプトキー一覧
export const PHASE_PROMPT_KEYS: Record<PhaseNumber, readonly PromptKey[]> = {
  1: ['phase1.prediction', 'phase1.goal', 'phase1.path', 'phase1.starting_point'],
  2: ['phase2.pre_questions', 'phase2.connection', 'phase2.qa'],
  3: ['phase3.summary', 'phase3.argument', 'phase3.resonance', 'phase3.application'],
  4: ['phase4.comparison', 'phase4.similarities', 'phase4.reasons', 'phase4.debate'],
  5: ['phase5.review', 'phase5.blurb', 'phase5.tweet', 'phase5.reflections', 'phase5.next'],
};

メリット:

  • プロンプト文言の変更がコード変更のみで完結します(DBマイグレーション不要)
  • 日英の文言を messages/ja.jsonmessages/en.json で一元管理できます

トレードオフ:

  • DBを見ただけではプロンプト内容が分からない
  • MVPではAI動的生成をしないので成立する設計ですが、将来ユーザーごとにプロンプトを変えたい場合は再検討が必要です

2. Server Actionsでデータ作成をワンショットで完結させる

本を登録すると、Phase 1とPhase 5(本全体のフェーズ)が自動的に作成されます。これをServer Action 1回で完結させています。

// src/features/books/actions/create-book.ts
'use server';

export async function createBook(formData: FormData) {
  const supabase = await createClient();
  const { id: userId } = await getAuthUser();

  // 1. 本を作成
  const { data: book } = await supabase
    .from('books')
    .insert({ user_id: userId, title: title.trim() /* ... */ })
    .select('id')
    .single();

  // 2. Phase 1(予測)を作成
  const { data: phase1 } = await supabase
    .from('phases')
    .insert({ book_id: book.id, chapter_id: null, phase_number: 1 })
    .select('id')
    .single();

  // 3. Phase 1 のプロンプトキーに対応する notes を一括作成
  const notes = PHASE_PROMPT_KEYS[1].map((prompt) => ({
    phase_id: phase1.id,
    prompt, // ← i18nキーをDBに保存
  }));
  await supabase.from('notes').insert(notes);

  // 4. Phase 5(自分化)も同様に作成
  // ...

  return { bookId: book.id };
}

データ変更用のAPIルートは作らず、Server Actionsだけで完結させています。他のアクション(削除や完了処理など)では revalidatePath を呼んでクライアント側のデータを自動更新しています。

3. localStorageフォールバック付きのオートセーブ

テキストエリアへの入力を1.5秒のデバウンスでSupabaseに自動保存する useAutosave フックを実装しました。

// src/features/notes/hooks/use-autosave.ts
type SaveStatus = 'idle' | 'dirty' | 'saving' | 'saved' | 'error';

export function useAutosave(noteId: string, initialContent: string) {
  // ① マウント時にlocalStorageのドラフトを優先復元
  const restoredContent = (() => {
    const draft = localStorage.getItem(`draft_${noteId}`);
    return draft !== null && draft !== initialContent ? draft : initialContent;
  })();

  const [content, setContent] = useState(restoredContent);
  const [status, setStatus] = useState<SaveStatus>(
    restoredContent !== initialContent ? 'dirty' : 'idle',
  );

  const save = useCallback(
    async (value: string) => {
      setStatus('saving');
      const result = await saveNote(noteId, value); // Server Action
      if (result.error) {
        setStatus('error');
      } else {
        localStorage.removeItem(`draft_${noteId}`); // ③ 保存成功時にドラフト削除
        setStatus('saved');
      }
    },
    [noteId],
  );

  const onChange = useCallback(
    (value: string) => {
      setContent(value);
      localStorage.setItem(`draft_${noteId}`, value); // ② 入力のたびにドラフト保存
      setStatus('dirty');
      // 1.5秒のデバウンスで自動保存
      clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => save(value), 1500);
    },
    [save],
  );

  // ④ アンマウント時に未保存があれば保存
  useEffect(
    () => () => {
      if (latestContentRef.current !== lastSavedRef.current) {
        save(latestContentRef.current);
      }
    },
    [],
  );

  return { content, status, onChange };
}

設計のポイント:

  • localStorageをフォールバックに使う: ネットワークエラーやブラウザ離脱時でも入力内容を失いません
  • 状態を5種類で管理: idle → dirty → saving → saved のフロー。UIには保存中・完了・エラーの3状態だけ表示しています
  • アンマウント時の保存: ページ遷移時にデバウンス待ちの内容があれば即保存します

ステータス

⚡ ハマったポイント

Phase 2のQAペアUI

Phase 2(対話)では「読む前に疑問を立て、読後に答えを記録する」というフローがあります。最初は通常のテキストエリアで実装したのですが、疑問と答えが分離しているとUXがイマイチでした。

結局、疑問と答えをJSONで1つのcontentカラムに保存する専用エディタを作りました。

// QAペアのデータはJSONで保存
const handleUpdate = (updates: Partial<typeof qaData>) => {
  const newData = { ...qaData, ...updates };
  onChange(JSON.stringify(newData)); // useAutosaveに渡す
};

テキストとJSONが混在するのは少し気持ち悪いですが、DBスキーマを変更せずにQAペアUIを実現できました。

✍️ まとめ

データ変更はすべてServer Actionsで完結させ、Route Handlerは外部APIプロキシ(Google Books検索)と認証コールバックだけに留めました。データの流れが「Server Action → DB → revalidatePath → RSC再レンダリング」で一方向になるので、状態管理がシンプルに保てます。

個人開発でNext.js 16の構成を検討している方の参考になれば嬉しいです。設計のアドバイスがあれば、ぜひコメントで教えてください。

特にPhase 2のQAペアをJSON保存している設計は、もっと良いやり方がある気がしています。知見がある方ぜひ。

🔗 実り読み(ReadHarvest)を試す

Discussion