【個人開発】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.jsonとmessages/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保存している設計は、もっと良いやり方がある気がしています。知見がある方ぜひ。
Discussion