Next.js + Supabase 無料プランで個人開発アプリを0円運用する方法

はじめに
積読・積みプラ・積みゲーを崩すWebアプリ「TsumiBreak」を、月0円で運用しています。構成は Vercel(Hobbyプラン)と Supabase(Freeプラン)です。
無料プランで運用するには、「お金がかからない」だけでなく「無料の範囲を超えにくい」「サーバーを持たなくても安全に作れる」設計が必要でした。この記事では、そのために工夫したことをまとめます。
- 認可はアプリではなくDBのRLSに任せる
- 集計はビューで、RLSを効かせたまま行う
- シェア画像はDBに保存せず、URLから毎回作る
- 管理画面は「専用テーブル+関数」で守る
- 無料プランの落とし穴
構成と費用
役割 | サービス | プラン | 費用 |
|---|---|---|---|
ホスティング | Vercel | Hobby | 0円 |
認証・DB | Supabase | Free | 0円 |
本の書誌情報 | openBD | — | 0円 |
商品情報 | Yahoo!ショッピング商品検索API | — | 0円 |
独自ドメインを取るときだけ、ドメインの年額費用がかかります。
1. 認可はDBのRLSに任せる
Supabaseでは、ブラウザやサーバーからDBに直接クエリを投げます。そのため「自分のデータしか見られない」ことを、アプリのコードではなくDBの行レベルセキュリティ(RLS)で保証するのが基本です。
アプリ側で where user_id = ... を付け忘れても、RLSがあればほかの人のデータは返りません。
alter table public.items enable row level security;
create policy "items_all_own" on public.items for all
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
auth.uid() は (select ...) で囲む
using (auth.uid() = user_id) と書くと、auth.uid() が行ごとに呼ばれます。(select auth.uid()) と囲むと、PostgreSQLが1回だけ計算して使い回してくれるので、行が多いときに速くなります。Supabaseの公式ドキュメントでも勧められている書き方です。
無料プランのDBは性能が控えめなので、こうした小さな工夫が効いてきます。
子テーブルは「親も自分のもの」まで確認する
作業ログの logs テーブルは、どのアイテムのログかを item_id で持っています。user_id が自分であることだけを確かめると、他人のアイテムのIDを指定して、自分のログとして書き込むことができてしまいます。
そこで、書き込むときは「そのアイテムも自分のものか」まで確かめています。
create policy "logs_all_own" on public.logs for all
using ((select auth.uid()) = user_id)
with check (
(select auth.uid()) = user_id
and exists (select 1 from public.items i where i.id = item_id and i.user_id = (select auth.uid()))
);
入力チェックもDBに持たせる
文字数や値の範囲は、アプリ(zod)でも確かめていますが、DBの check 制約にも書いています。Server Actionは外から直接呼ばれることもあるので、最後の砦をDBに置いておくと安心です。
title text not null check (char_length(title) between 1 and 200),
price integer not null default 0 check (price >= 0),
status text not null default 'stacked'
check (status in ('stacked', 'in_progress', 'completed')),
constraint completed_at_consistency
check ((status = 'completed') = (completed_at is not null))
最後の制約は「完了しているなら完了日時がある」「完了していないなら完了日時はない」を保証しています。状態と日時が食い違ったデータが入らないので、集計で悩まずに済みます。
2. 集計はビューで行う(security_invoker)
ダッシュボードには「積んでいる金額」「今月崩した件数と金額」を出しています。これをアプリ側で全件取得して計算すると、通信量もVercelの処理時間も増えます。
そこで、PostgreSQLのビューで集計して、結果の1行だけを取ってくるようにしました。
create view public.dashboard_stats
with (security_invoker = true) as
select
user_id,
-- (価格未登録の件数など、一部の列は省略)
coalesce(sum(price) filter (where status <> 'completed'), 0)::integer as stacked_amount,
(count(*) filter (where status <> 'completed'))::integer as stacked_count,
coalesce(sum(price) filter (where status = 'completed'
and date_trunc('month', completed_at at time zone 'Asia/Tokyo')
= date_trunc('month', now() at time zone 'Asia/Tokyo')), 0)::integer as month_completed_amount,
(count(*) filter (where status = 'completed'
and date_trunc('month', completed_at at time zone 'Asia/Tokyo')
= date_trunc('month', now() at time zone 'Asia/Tokyo')))::integer as month_completed_count
from public.items
group by user_id;
ポイントは2つです。
security_invoker = trueを付ける:PostgreSQLのビューは、何も指定しないとビューを作った人の権限で動きます。そうなると、元のテーブルのRLSが効かず、全ユーザーの集計が見えてしまいます。security_invokerを付けると、ビューを読んだ人の権限で動くので、RLSがそのまま効きます。- 「今月」は日本時間で区切る:DBの時刻はUTCなので、そのまま月で区切ると、日本の月初めの朝9時までの完了が前の月に入ってしまいます。
at time zone 'Asia/Tokyo'で日本時間に直してから比べています。
3. シェア画像はDBに保存しない
崩し完了したときに、Xに画像付きでシェアできるようにしています。
シェア画像を作ってStorageに保存すると、ストレージ容量を使い、古い画像を消す仕組みも要ります。そこで、シェアに必要な情報(カテゴリ名・件数・金額)をURLのパラメータに入れて、画像はアクセスのたびに作ることにしました。
https://tsumi-break.vercel.app/share?c=プラモ&n=3&a=6200
このページのOGP画像は /api/og?c=プラモ&n=3&a=6200 で、Next.jsの ImageResponse(next/og)を使って作ります。
URLの値は必ず検証する
URLは誰でも書き換えられます。そのまま画像に描くと、好きな文言を「TsumiBreakの画像」として作れてしまいます。そこで、値の形と範囲を検証し、おかしければ通常のトップ用の画像に切り替えます。
/** 公開ページ・OGP 用。値は表示専用なので、範囲外や不正な形式は null にしてフォールバックさせる */
export function parseShareParams(sp: Record<string, string | string[] | undefined>): ShareParams | null {
const categoryName = parseCategoryName(sp.c)
const count = parseIntIn(sp.n, 1, 999)
const amount = parseIntIn(sp.a, 0, 100_000_000)
if (categoryName === null || count === null || amount === null) return null
return { categoryName, count, amount }
}
カテゴリ名はユーザーが自由に付けられるので、長さの上限と制御文字のチェックもしています。
日本語フォントは「使う文字だけ」取ってくる
ImageResponse で日本語を描くには、日本語フォントのデータを渡す必要があります。ただ、日本語フォントは数MBあり、毎回読み込むと遅くなります。
Google Fontsには、text= に文字を渡すとその文字だけを含むフォントを返してくれる機能があります。画像に描く文字だけを渡して、小さなフォントを取ってきています。
async function loadFont(text: string): Promise<ArrayBuffer | null> {
try {
const css = await (
await fetch(`https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@700&text=${encodeURIComponent(text)}`)
).text()
const match = css.match(/src: url\((.+?)\) format\('(opentype|truetype)'\)/)
if (!match) return null
const res = await fetch(match[1])
return res.ok ? await res.arrayBuffer() : null
} catch {
return null
}
}
フォントの取得に失敗したときは、標準のフォントで描きます。画像には Cache-Control: public, max-age=86400, immutable を付けているので、同じURLの画像は作り直さずにキャッシュから返ります。
シェアページは検索結果に出さない
シェア用のURLは、パラメータの組み合わせが無数にあります。検索エンジンに全部登録されると、中身の薄いページが大量にできてしまいます。そこで、シェアページは noindex にして、正規URL(canonical)はトップページにしています。
robots: { index: false, follow: true },
alternates: { canonical: '/' },
4. 管理画面は「専用テーブル+関数」で守る
登録数や利用状況を見るための管理画面(/admin)も作りました。管理者は admins テーブルで管理しています。
create table public.admins (
user_id uuid primary key references auth.users(id) on delete cascade,
created_at timestamptz not null default now()
);
-- ポリシーを作らないので、アプリの通常の接続からは読み書きできない
alter table public.admins enable row level security;
revoke all on public.admins from anon, authenticated;
RLSを有効にしてポリシーを1つも作らないと、そのテーブルはアプリからは誰も読み書きできない状態になります。管理者の登録は、SupabaseのSQLエディタから直接行います。
管理者かどうかの判定や集計は、security definer の関数にまとめました。関数の中では、最初に管理者かどうかを確かめます。
create function public.is_admin() returns boolean
language sql stable security definer set search_path = '' as $$
select exists (select 1 from public.admins where user_id = (select auth.uid()));
$$;
security definer の関数は、作った人(強い権限)で動きます。そのため、set search_path = '' を付けて、テーブル名は必ず public.admins のようにスキーマ付きで書いています。これを忘れると、検索パスを細工して別のテーブルを読ませる攻撃の余地が残ります。
管理画面のページでは、管理者でなければ403ではなく404を返しています。管理画面があること自体を知られないようにするためです。
集計では、E2Eテストで作ったユーザー(@example.test のメールアドレス)を除いています。テストを回すたびに「新規登録」が増えて、数字が狂うのを防ぐためです。
5. 無料プランの落とし穴
運用してみて、気を付けるべき点がいくつかありました。
Supabase Freeは1週間アクセスがないと止まる
Supabaseの無料プランは、1週間アクセスがないとプロジェクトが一時停止します。利用者が少ないうちは、本当に止まることがあります。自分で毎日使っていれば止まりません。
また、無料プランで同時に動かせるプロジェクトは2つまでです。本番用と開発用で、ちょうど2つになります。
Proxy(旧Middleware)を通すパスを絞る
Next.js 16では、Middlewareが proxy.ts という名前になりました。Supabaseのログイン状態を保つために、ここでセッションを更新しています。
ただし、何も指定しないと画像やアイコンへのアクセスでも毎回動いて、Vercelの処理回数を使ってしまいます。静的ファイルやOGP画像、Service Workerは対象から外しています。
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|serwist|api/og|icons|manifest.webmanifest|~offline|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)',
],
}
外部APIはキャッシュする
バーコードから商品名を調べる外部APIは、Next.jsの fetch に next: { revalidate: 86400 } を付けて1日キャッシュしています。商品名や価格はほとんど変わらないので、同じ商品を何度スキャンしてもAPIを何度も呼びません。APIの利用回数の上限にも当たりにくくなります。
おわりに
0円で運用するために一番効いたのは、「データを持たない」「何度も計算しない」設計でした。
- 認可と入力チェックはDBに任せて、アプリのコードを減らす
- 集計はDBのビューで行い、
security_invokerでRLSを効かせたままにする - シェア画像は保存せず、検証したURLの値から作ってキャッシュする
- 管理用のテーブルは、ポリシーを作らずアプリから見えなくする
- Proxyと外部APIの呼び出し回数を減らす
小さく始める個人開発の参考になればうれしいです。


