32blogby StudioMitsu

Next.jsでいいねボタンを実装する方法(Upstash Redis)

Next.js App Router + Upstash Redisでいいねボタンを実装する手順と、Vercelの従量課金環境で運用した結果わかったコスト問題を解説する。

12 min read

当記事にはアフィリエイト広告が含まれています

目次

ブログにいいねボタンを付けたくなった。認証なし、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のセットアップ

  1. Upstash Console でアカウントを作成
  2. Create Database → リージョンを選択(日本向けなら ap-northeast-1
  3. 作成後に表示される UPSTASH_REDIS_REST_URLUPSTASH_REDIS_REST_TOKEN をコピー

Next.jsプロジェクトの .env.local に追加する。

bash
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=

SDKをインストールする。

bash
npm install @upstash/redis

Redisクライアントのヘルパーを作る。

ts
// 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_URLUPSTASH_REDIS_REST_TOKEN を環境変数から自動で読む。シングルトンにしているのは、Serverless環境でのコネクション再利用のためだ。


API Routeを作る

いいね数の取得(GET)と追加(POST)を1つのRoute Handlerにまとめる。

ts
// 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記事あたりの上限。不正な大量リクエストへの防御
  • paramsPromise — Next.js 15以降、動的ルートの params は非同期になった

LikeButtonコンポーネントを作る

クライアントコンポーネントとして実装する。

tsx
// 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リクエストを送る。最新の合計値を表示するためだ。これが後述するコスト問題の原因になる。


記事ページに組み込む

tsx
// 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等)に固有 のものだ。

さくらのVPSXServer VPS のような月額固定のVPSでは、API Routeが1万回叩かれても追加コストはゼロだ。サーバーは常時稼働しており、リクエスト数に応じた課金は発生しない。

つまり:

プラットフォームいいねAPI 1万回/月追加コスト
VPS(さくら、XServer等)サーバーが処理¥0(月額固定内)
Vercel ProServerless Function 1万回$0(100万回無料枠内)
Vercel HobbyServerless Function 1万回CPU時間として加算

Vercel Proの100万回無料枠内なら直接的な追加費用はないが、CPU時間として$20クレジットを消費する。 他の機能(SSR, Middleware)と合わせてクレジットを使い切ると、従量課金に切り替わる。


外した判断基準

最終的にこのいいねボタンは本番から外した。理由はシンプルだ。

  1. 使用率が低かった。 サイドバーの下に配置したが、実際にいいねを押すユーザーはほぼいなかった
  2. 全ページで毎回GETが走る。 押されなくても、ページを開くだけでAPI Routeが起動する
  3. CPU予算を他に回したかった。 SSRやMiddlewareなど、SEOに直結する処理にCPU時間を使うべきだった

機能として壊れていたわけではない。従量課金環境で「コストに見合うか」を判断した結果だ。


まとめ

いいねボタン自体の実装はシンプルだ。Upstash Redis + API Routeで30分で動く。デバウンスとlocalStorageを組み合わせれば、認証なしでもまともに使える。

ただし、Vercelのような従量課金プラットフォームでは「軽い処理 × 全ページ × 全アクセス」が積み重なる。機能を追加するときは、1リクエストあたりのコストではなく、月間の総リクエスト数で判断する 習慣をつけたほうがいい。

VPSで運用するなら、この問題は一切気にしなくていい。

Vercelの従量課金の仕組みと防御策についてはSpend Managementの設定ガイドで詳しく解説している。