ブログにいいねボタンを付けたくなった。認証なし、DB不要、クリックするだけでカウントが増える——シンプルなやつだ。
Next.js App RouterのAPI Route + Upstash Redisで実装したら30分で動いた。ただし、Vercelにデプロイして本番運用した結果、従量課金との相性問題 に気づいて最終的に外すことになった。
この記事では実装の全コードと、従量課金プラットフォームで運用する際に知っておくべきコストの話をまとめる。
技術スタック
| 要素 | 技術 |
|---|---|
| フレームワーク | Next.js 16(App Router) |
| 状態管理 | React useState + localStorage |
| バックエンド | Next.js API Route(Route Handler) |
| データストア | Upstash Redis |
| デプロイ先 | Vercel |
Upstash Redisを選んだ理由は、無料枠が月50万コマンドあり、REST APIベースでServerless環境と相性がいいからだ。
Upstash Redisのセットアップ
- Upstash Console でアカウントを作成
- Create Database → リージョンを選択(日本向けなら
ap-northeast-1) - 作成後に表示される UPSTASH_REDIS_REST_URL と UPSTASH_REDIS_REST_TOKEN をコピー
Next.jsプロジェクトの .env.local に追加する。
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=
SDKをインストールする。
npm install @upstash/redis
Redisクライアントのヘルパーを作る。
// lib/redis.ts
import { Redis } from "@upstash/redis";
let _redis: Redis | null = null;
export function getRedis(): Redis {
if (!_redis) {
_redis = Redis.fromEnv();
}
return _redis;
}
Redis.fromEnv() は UPSTASH_REDIS_REST_URL と UPSTASH_REDIS_REST_TOKEN を環境変数から自動で読む。シングルトンにしているのは、Serverless環境でのコネクション再利用のためだ。
API Routeを作る
いいね数の取得(GET)と追加(POST)を1つのRoute Handlerにまとめる。
// app/api/likes/[slug]/route.ts
import { NextResponse } from "next/server";
import { getRedis } from "@/lib/redis";
const SLUG_CAP = 999_999;
const MAX_BATCH = 32;
type Params = { params: Promise<{ slug: string }> };
export async function GET(_req: Request, { params }: Params) {
try {
const { slug } = await params;
const redis = getRedis();
const total = (await redis.get<number>(`likes:${slug}`)) ?? 0;
return NextResponse.json({ total });
} catch {
return NextResponse.json({ error: "unavailable" }, { status: 503 });
}
}
export async function POST(req: Request, { params }: Params) {
try {
const { slug } = await params;
const body = await req.json().catch(() => ({}));
const count = Math.min(
Math.max(Math.floor(Number(body.count) || 1), 1),
MAX_BATCH
);
const redis = getRedis();
const current = (await redis.get<number>(`likes:${slug}`)) ?? 0;
if (current >= SLUG_CAP) {
return NextResponse.json({ total: current });
}
const add = Math.min(count, SLUG_CAP - current);
const total = await redis.incrby(`likes:${slug}`, add);
return NextResponse.json({ total });
} catch {
return NextResponse.json({ error: "unavailable" }, { status: 503 });
}
}
ポイント:
MAX_BATCH = 32— 1リクエストで最大32回分のいいねをまとめて送信できる。後述するデバウンス処理と組み合わせるSLUG_CAP— 1記事あたりの上限。不正な大量リクエストへの防御paramsがPromise— Next.js 15以降、動的ルートのparamsは非同期になった
LikeButtonコンポーネントを作る
クライアントコンポーネントとして実装する。
// components/LikeButton.tsx
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
const MAX = 32;
const KEY = "likes:";
const DEBOUNCE_MS = 3000;
function fmt(n: number) {
return n.toLocaleString("en-US");
}
export function LikeButton({ slug }: { slug: string }) {
const [total, setTotal] = useState(0);
const [user, setUser] = useState(0);
const [ready, setReady] = useState(false);
const pendingRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const slugRef = useRef(slug);
slugRef.current = slug;
const flush = useCallback(() => {
const count = pendingRef.current;
if (count <= 0) return;
pendingRef.current = 0;
const s = slugRef.current;
fetch(`/api/likes/${s}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ count }),
})
.then((r) => (r.ok ? r.json() : null))
.then((d: { total: number } | null) => {
if (d) setTotal(d.total);
})
.catch(() => {});
}, []);
// ページ離脱時にフラッシュ
useEffect(() => {
const onUnload = () => flush();
window.addEventListener("beforeunload", onUnload);
return () => {
window.removeEventListener("beforeunload", onUnload);
flush();
};
}, [flush]);
// 初期ロード: localStorageからユーザー数、サーバーから合計数
useEffect(() => {
const cached = localStorage.getItem(KEY + slug);
if (cached) setUser(parseInt(cached, 10));
fetch(`/api/likes/${slug}`)
.then((r) => (r.ok ? r.json() : null))
.then((d: { total: number } | null) => {
if (d) setTotal(d.total);
})
.catch(() => {});
setReady(true);
}, [slug]);
const hit = useCallback(() => {
if (user >= MAX) return;
const n = user + 1;
setUser(n);
localStorage.setItem(KEY + slug, String(n));
setTotal((p) => p + 1);
// デバウンス: クリックをまとめて3秒後に1回だけPOST
pendingRef.current += 1;
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(flush, DEBOUNCE_MS);
}, [user, slug, flush]);
return (
<button onClick={hit} aria-label={`Like (${total})`}>
⚡ {ready ? fmt(total) : "—"}
</button>
);
}
設計判断
認証なし、localStorageで制限。 ユーザーごとに最大32回。localStorageをクリアすれば再度押せるが、ブログのいいねにそこまでの厳密さは不要。認証を入れるとユーザー体験が悪化する。
デバウンス(3秒)。 連打を1リクエストにまとめる。10回連打しても、サーバーへのPOSTは3秒後の1回だけ。count: 10 をまとめて送信する。
GETは毎回。 ページを開くたびに /api/likes/[slug] にGETリクエストを送る。最新の合計値を表示するためだ。これが後述するコスト問題の原因になる。
記事ページに組み込む
// app/[locale]/[category]/[slug]/page.tsx
import { LikeButton } from "@/components/LikeButton";
// ... ページコンポーネント内
<article>
{/* 記事本文 */}
</article>
<aside>
<LikeButton slug={slug} />
</aside>
これで動く。記事ページを開くといいね数が表示され、ボタンを押すとカウントが増える。
従量課金プラットフォームで運用した結果
このいいねボタンを108記事×3言語(324ページ)のサイトで本番運用した。デプロイ先はVercel Pro。
何が起きるか
ページを開くたびに、クライアントからAPI Route /api/likes/[slug] にGETリクエストが飛ぶ。このAPI RouteはServerless Functionとして実行される。
Vercelでは、Serverless Functionの実行は Fluid Active CPU として課金される。1リクエストあたりの処理時間は数ミリ秒と軽いが、リクエスト数に比例してFunction Invocationsが積み上がる。
コスト計算
Vercel Proの料金体系で計算する。
Function Invocations: 月100万回まで無料、超過 $0.60/100万回
Fluid Active CPU: $0.128/CPU時間〜($20クレジットで相殺)
月間1万PVのサイトの場合:
- GETリクエスト: 1万回/月(ページ表示ごとに1回)
- POSTリクエスト: 約500回/月(いいねを押す人が5%として)
- 合計: 約10,500 Function Invocations/月
月1万PVなら100万回の無料枠に余裕で収まる。ただし、これはいいねボタン単体の話だ。 サイト全体のSSR、Middleware、他のAPI Routeと合算されることを忘れてはいけない。
実際に発生した問題
僕のサイトでは、いいねボタン以外にも複数の要因が重なった結果、Vercel Hobbyプランの Fluid Active CPU 4時間/月の上限に到達 した。
要因の内訳:
- SSR(記事ページのサーバーサイドレンダリング): 最大の消費
- Middleware(next-intlのロケール判定、全リクエストで実行): 14.1%
- API Route(いいねボタンのGET): 不明(ルート別の内訳はVercelダッシュボードで確認できない)
- ボットが存在しないURLを叩く → SSRが走る
いいねボタンだけが原因ではない。ただし、使用率が低い機能がリクエストごとにServerless Functionを起動する構造 は、従量課金環境では無駄なコストだ。
VPSなら問題にならない
この問題は 従量課金プラットフォーム(Vercel, AWS Lambda, Cloudflare Workers等)に固有 のものだ。
さくらのVPS や XServer VPS のような月額固定のVPSでは、API Routeが1万回叩かれても追加コストはゼロだ。サーバーは常時稼働しており、リクエスト数に応じた課金は発生しない。
つまり:
| プラットフォーム | いいねAPI 1万回/月 | 追加コスト |
|---|---|---|
| VPS(さくら、XServer等) | サーバーが処理 | ¥0(月額固定内) |
| Vercel Pro | Serverless Function 1万回 | $0(100万回無料枠内) |
| Vercel Hobby | Serverless Function 1万回 | CPU時間として加算 |
Vercel Proの100万回無料枠内なら直接的な追加費用はないが、CPU時間として$20クレジットを消費する。 他の機能(SSR, Middleware)と合わせてクレジットを使い切ると、従量課金に切り替わる。
外した判断基準
最終的にこのいいねボタンは本番から外した。理由はシンプルだ。
- 使用率が低かった。 サイドバーの下に配置したが、実際にいいねを押すユーザーはほぼいなかった
- 全ページで毎回GETが走る。 押されなくても、ページを開くだけでAPI Routeが起動する
- CPU予算を他に回したかった。 SSRやMiddlewareなど、SEOに直結する処理にCPU時間を使うべきだった
機能として壊れていたわけではない。従量課金環境で「コストに見合うか」を判断した結果だ。
まとめ
いいねボタン自体の実装はシンプルだ。Upstash Redis + API Routeで30分で動く。デバウンスとlocalStorageを組み合わせれば、認証なしでもまともに使える。
ただし、Vercelのような従量課金プラットフォームでは「軽い処理 × 全ページ × 全アクセス」が積み重なる。機能を追加するときは、1リクエストあたりのコストではなく、月間の総リクエスト数で判断する 習慣をつけたほうがいい。
VPSで運用するなら、この問題は一切気にしなくていい。
Vercelの従量課金の仕組みと防御策についてはSpend Managementの設定ガイドで詳しく解説している。