はじめに
こんにちは、もんすんです。
私は今、Raspberry Pi の上で Claude Code を毎日 cron で回し、技術・ビジネスのトレンドを自動で集めてMarkdownに書き出す——という仕組みを動かしています。
ここで地味に困ったことが起きました。
「自動化が毎日記事を吐いてくれる。でも、それを表示するために毎回ビルドし直すのは嫌だ。」
最初は Next.js の静的生成(SSG)で作っていたのですが、記事が追加されるたびに next build → デプロイが必要でした。毎日勝手に増えるコンテンツと、毎回手で叩くビルド。明らかに相性が悪い。
そこで、「DBを単一の真実(source of truth)にして、リクエストのたびに動的レンダリングする」 構成に作り直しました。この記事はその設計の共有です。
この記事で扱うのは「自作の個人用トレンドビューア」の中身です。Pi上の自動化が吐いた記事を、リビルドなしでスマホから読むためのフロントエンドです。
対象読者・前提
- Next.js(App Router)と Supabase の基礎がある人
- 「コンテンツが増えるたびにビルドが要る」構成に困っている人
- 個人開発で「とにかく運用がラクな構成」を探している人
この記事のゴール
- リビルド不要でコンテンツが反映される仕組みの全体像をつかむ
- Dynamic SSR(
force-dynamic)+ Supabase の組み合わせ方を知る - Server / Client コンポーネントの分割で踏みやすい落とし穴を避ける
結論:DBを真にして、毎リクエストSSRするだけ
先に結論です。やったことは、ほぼこれだけです。
-
データの真実は Supabase の
postsテーブル1つ-
date+categoryをユニークキーにして「1行 = 1カード」
-
- ページは
export const dynamic = 'force-dynamic'で、リクエストのたびにサーバーでSSR。 - コンテンツ追加は、Pi 上のスクリプトが Supabase にある
postsテーブルへ upsert するだけ。- ビルドもデプロイも不要。
generateStaticParams も、API Route も、クライアント側 fetch も使いません。「サーバーが毎回DBを読んで返す」だけのシンプルな構成です。
構成の全体像
[Raspberry Pi: cron + Claude Code]
│ Markdownを生成
▼
[upload-post.mjs] ──upsert──► [Supabase: posts テーブル]
▲
│ リクエスト毎にSELECT
│ (force-dynamic SSR)
[Next.js 16 App Router]
│
▼
スマホで閲覧
ポイントは、フロントエンドは「DBを読んで描画するだけ」に徹していることです。
コンテンツの生成・投入は完全に別系統(Pi側)に切り離しています。
技術スタック
| 種類 | 採用 |
|---|---|
| フレームワーク | Next.js 16(App Router) |
| データストア | Supabase(Postgres)。@supabase/supabase-js
|
| 取得方式 | Dynamic SSR(force-dynamic)。anonキーで read、RLSで public SELECT |
| Markdown | react-markdown + remark-gfm + gray-matter |
| スタイル | Tailwind CSS v4 |
| 言語 | TypeScript |
設計の核:force-dynamic で毎リクエストSSR
App Router では、ページファイルに次の1行を置くだけで、そのルートがリクエストのたびにサーバーレンダリングされるようになります。
// app/page.tsx
export const dynamic = 'force-dynamic';
export default async function Home() {
const latestDate = await getLatestDate(); // サーバーでDBを読む
redirect(`/date/${latestDate}`);
}
これにより、ビルド時にページを固める SSG とは逆に、「アクセスされた瞬間の最新のDBの状態」がそのまま画面になります。コンテンツがどれだけ増えても、ビルドは一度きり(デプロイ時)で済みます。
データ取得はサーバー専用モジュールに閉じる
Supabase を読むコードは、すべてサーバー専用のローダー(例: postLoader.ts)に集約します。
// utils/supabase.ts (サーバーでのみ動く)
import { createClient as createSupabaseClient } from '@supabase/supabase-js';
export function createClient() {
return createSupabaseClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, // 読み取り専用(RLSでpublic SELECT)
);
}
// utils/postLoader.ts (サーバーでのみ動く)
import { createClient } from '@/utils/supabase';
export async function getPostsForDate(date: string) {
const supabase = createClient();
const { data } = await supabase
.from('posts')
.select('date, category, title, tags, content')
.eq('date', date);
return (data ?? []).map(mapRowToCard); // 1行 = 1カード
}
anon キーは読み取り専用で、RLS(Row Level Security)で public SELECT のみ許可しています。書き込みに使うサービスロールキーは、後述の投入スクリプトでしか使いません。アプリ側のコードには一切登場させない、という線引きが大事です。
Server / Client の分割ルール
App Router で一番ハマりやすいのがここです。私は次のルールで運用しています。
| 種類 | 置き場 | 例 |
|---|---|---|
| DBアクセス・env読み取り | サーバーコンポーネント |
postLoader.ts / supabase.ts
|
useState / useRouter を使うUI |
クライアントコンポーネント('use client') |
タブ切替・カレンダーモーダル |
// ❌ サーバー専用モジュールをクライアントでimport
'use client';
import { getPostsForDate } from '@/utils/postLoader'; // env/anonキーがクライアントに漏れる危険
// ✅ 正解:サーバーで取得 → propsでクライアントに渡す
// (page.tsx = サーバー) でデータを取得し、 <Dashboard posts={posts} /> に渡す
データ取得は必ずサーバー側で完結させ、クライアントには取得済みの配列を props で渡すだけにします。こうするとキーの露出事故が構造的に起きません。
ルーティング:日付ドリブンダッシュボード
ルート設計はシンプルです。
-
/→ 最新日付へ redirect -
/date/[date]→ その日付のダッシュボード(1日分のカード一覧)
// app/date/[date]/page.tsx
export const dynamic = 'force-dynamic';
export default async function DatePage({ params }: { params: Promise<{ date: string }> }) {
const { date } = await params; // Next.js 16 では params が Promise
const posts = await getPostsForDate(date);
return <DateDashboard posts={posts} />;
}
フィードのタブの切り替えは、渡された配列をクライアント側でフィルタするだけにしています。タブごとに再フェッチしません。最初に1日分を取ってきて、あとはメモリ上で出し分けるので、操作はサクサクです。
コンテンツ投入:upsertするだけ
Pi 側の自動化は、Markdown を生成したら投入スクリプト(upload-post.mjs)を叩くだけです。
# 1ファイル投入
node scripts/upload-post.mjs contents/trend/2026-06-07.md
# ディレクトリ一括投入
node scripts/upload-post.mjs --dir contents/digest
スクリプトは Markdown の frontmatter から category と date を読み取り、posts テーブルへ upsert します(date + category がユニークキーなので、同じ日の同じカテゴリは上書き)。
ここで初めてサービスロールキーを使います。書き込みはこのスクリプトだけ、という役割分担です。
投入が終われば、次に誰かがアクセスした瞬間のSSRで、もう新しいカードが画面に出ています。ビルドもデプロイも、再起動すらいりません。
ハマりどころ
1. サーバー専用モジュールのクライアント混入
前述のとおり、postLoader.ts / supabase.ts をうっかり 'use client' なコンポーネントから import すると、ビルドエラーになるか、最悪 env が露出します。「DBに触るコードはサーバーだけ」を徹底。
2. ローカル検証でサーバーが“刈られる”
私の Pi 環境では、next start のような常駐サーバーがプロセスごと停止されることがあります(Exit code 144)。E2E でローカルサーバーを立てる検証は不安定なので、ポートを使わず Supabase に直接クエリする node スクリプトで代替検証しています。「サーバーを立てないと確認できない」状態を避けておくと、CI でも個人環境でもラクです。
3. force-dynamic のコスト感
毎リクエストでDBを読むので、アクセスが多いサービスではキャッシュ戦略が要ります。ただ「自分しか見ない個人ツール」では、リクエスト数がたかが知れているので、シンプルさを優先して割り切っています。用途次第ですね。
まとめ
毎日コンテンツが増える個人ツールでは、「DBを真実にして毎リクエストSSR」 が圧倒的にラクでした。
- コンテンツ追加 =
postsに upsert するだけ。ビルド・デプロイ不要 - 取得はサーバー専用ローダーに閉じ、クライアントには props で渡す
- ルートは「最新日付へredirect → 日付ダッシュボード」だけ
「静的生成にこだわってビルドと格闘していた時間は何だったのか」というくらい、運用が静かになりました。
見た目(サイバーパンク風UI)を Tailwind CSS v4 だけで作った話と、日次まとめと無限ストリームを1テーブルで同居させたデータモデルの話は、別記事で書きます。よければそちらもどうぞ。
それでは、良い個人開発ライフを。