1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

はじめに

こんにちは、もんすんです。

私は今、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 から categorydate を読み取り、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テーブルで同居させたデータモデルの話は、別記事で書きます。よければそちらもどうぞ。

それでは、良い個人開発ライフを。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?