32blogby StudioMitsu

How to Build a Like Button with Next.js and Upstash Redis

Step-by-step implementation of a like button using Next.js App Router and Upstash Redis, plus the cost lessons learned from running it on Vercel's usage-based billing.

8 min read
On this page

I wanted a like button for my blog. No auth, no database setup, just click and the count goes up — simple.

Next.js App Router API Routes + Upstash Redis got it working in 30 minutes. But after deploying to Vercel and running it in production, I discovered a cost problem with usage-based billing platforms and ended up removing it.

This article covers the full implementation code and the cost lessons from running it on a usage-based platform.


Tech Stack

ComponentTechnology
FrameworkNext.js 16 (App Router)
StateReact useState + localStorage
BackendNext.js API Route (Route Handler)
Data storeUpstash Redis
HostingVercel

I chose Upstash Redis because the free tier includes 500,000 commands/month, and its REST API works seamlessly with serverless environments.


Setting Up Upstash Redis

  1. Create an account at Upstash Console
  2. Create Database → select a region (us-east-1 for North America, ap-northeast-1 for Japan)
  3. Copy the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN

Add them to .env.local in your Next.js project.

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

Install the SDK.

bash
npm install @upstash/redis

Create a Redis client helper.

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() reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN from environment variables automatically. The singleton pattern reuses the connection in serverless environments.


Building the API Route

A single Route Handler handles both fetching the count (GET) and incrementing it (POST).

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 });
  }
}

Key points:

  • MAX_BATCH = 32 — accepts up to 32 likes per request, used with client-side debouncing
  • SLUG_CAP — per-article cap to defend against abuse
  • params is a Promise — since Next.js 15, dynamic route params are async

Building the LikeButton Component

This is a client component.

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(() => {});
  }, []);

  // Flush on page unload
  useEffect(() => {
    const onUnload = () => flush();
    window.addEventListener("beforeunload", onUnload);
    return () => {
      window.removeEventListener("beforeunload", onUnload);
      flush();
    };
  }, [flush]);

  // Initial load: localStorage for user count, server for total
  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);

    // Debounce: accumulate clicks, flush once after 3s of inactivity
    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>
  );
}

Design Decisions

No auth, localStorage-based limits. Each user can press up to 32 times. Clearing localStorage resets the count, but strict enforcement isn't worth the UX cost for a blog like button.

Debouncing (3 seconds). Rapid clicks are batched into a single request. Clicking 10 times results in one POST with count: 10 after 3 seconds of inactivity.

GET on every page load. Every time a page opens, a GET request fires to /api/likes/[slug] to fetch the current total. This becomes the cost issue described below.


Adding It to the Article Page

tsx
// app/[locale]/[category]/[slug]/page.tsx
import { LikeButton } from "@/components/LikeButton";

// ... inside the page component
<article>
  {/* Article content */}
</article>
<aside>
  <LikeButton slug={slug} />
</aside>

That's it. The like count appears on load, and clicks increment it.


What Happened in Production

I ran this like button on a site with 108 articles × 3 languages (324 pages) deployed on Vercel Pro.

The Mechanism

Every page load triggers a client-side GET request to the API Route /api/likes/[slug]. This API Route executes as a Serverless Function.

On Vercel, Serverless Function execution is billed as Fluid Active CPU time. Each request is lightweight (a few milliseconds), but Function Invocations accumulate proportionally to request count.

Cost Calculation

Using Vercel Pro's pricing:

Function Invocations: 1M/month included, then $0.60/1M
Fluid Active CPU: $0.128/CPU-hour+ (offset by $20 credit)

For a site with 10,000 monthly pageviews:

  • GET requests: 10,000/month (one per page load)
  • POST requests: ~500/month (assuming 5% of visitors click)
  • Total: ~10,500 Function Invocations/month

At 10K PV/month, this fits comfortably within the 1M free invocations. But this is the like button alone. It gets added to the site's total SSR, Middleware, and other API Route invocations.

What Actually Happened

On my site, multiple factors combined to hit the Fluid Active CPU limit of 4 hours/month on Vercel's Hobby plan.

The breakdown:

  • SSR (server-side rendering of article pages): largest consumer
  • Middleware (next-intl locale detection, runs on every request): 14.1%
  • API Route (like button GET): unknown (Vercel's dashboard doesn't show per-route CPU breakdown)
  • Bots hitting non-existent URLs → triggering SSR

The like button wasn't the sole cause. But a low-usage feature that fires a Serverless Function on every page load is wasted cost in a usage-based environment.

This Doesn't Apply to VPS

This problem is specific to usage-based platforms (Vercel, AWS Lambda, Cloudflare Workers, etc.).

On a fixed-cost VPS, the server runs 24/7 regardless of request count. An API Route handling 10,000 requests costs nothing extra — it's all within the fixed monthly fee.

Platform10K like API calls/monthAdditional cost
VPS (fixed monthly)Server handles it$0 (included)
Vercel Pro10K Serverless Functions$0 (within 1M free tier)
Vercel Hobby10K Serverless FunctionsCounts toward CPU limit

On Vercel Pro, the 1M free invocations absorb the direct cost, but CPU time still consumes the $20 monthly credit. If other features (SSR, Middleware) exhaust the credit, usage-based billing kicks in.


Why I Removed It

I ended up removing the like button from production. The reasoning:

  1. Low usage. Placed below the sidebar table of contents, almost nobody clicked it
  2. GET fires on every page load. Even with zero clicks, every visitor triggered a Serverless Function
  3. CPU budget was better spent elsewhere. SSR and Middleware directly impact SEO — the like button didn't

The feature worked correctly. It was a cost-efficiency decision in a usage-based billing environment.


Wrapping Up

The like button implementation itself is straightforward. Upstash Redis + API Routes gets it working in 30 minutes. Debouncing and localStorage make it functional without authentication.

On usage-based platforms like Vercel, though, "lightweight per-request × every page × every visitor" adds up. When adding features, evaluate the total monthly request count, not the per-request cost.

If you're hosting on a VPS, none of this is a concern.

For a detailed guide on Vercel's usage-based billing and how to protect yourself, see Vercel Spend Management Guide.