32blogby Studio Mitsu

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.

by omitsu10 min read
On this page

You can build a like button with Next.js App Router and Upstash Redis in about 30 minutes — no auth required, no database provisioning, just a Route Handler plus localStorage for client-side state.

I wanted exactly that for my blog. Click and the count goes up — simple. Upstash Redis + API Routes got it working fast. 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 @upstash/redis 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. If you're already dealing with SSR optimization challenges, adding another per-request function invocation compounds the problem.


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. Applying build optimization techniques was a better use of that budget

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


FAQ

Does Upstash Redis work with Next.js App Router?

Yes. Upstash Redis provides a REST-based SDK that works in any serverless environment, including Next.js App Router Route Handlers. Since it uses HTTP instead of persistent TCP connections, there's no connection pooling issue in serverless functions.

How much does Upstash Redis cost for a like button?

The free tier includes 500,000 commands/month. A like button with 10,000 monthly page views would use roughly 10,500 commands (GET on every load + occasional POSTs), well within the free limit. The cost concern comes from the Vercel Serverless Function invocations, not Upstash itself.

Can I use a database instead of Redis for the like count?

You can, but Redis is a better fit. Like counts are simple integer increments — Redis's INCRBY command handles this atomically in microseconds. A relational database would work but adds unnecessary latency and connection management complexity in serverless environments.

How do I prevent like button abuse without authentication?

The implementation uses three layers: localStorage limits each browser to 32 clicks, MAX_BATCH caps each request at 32 increments, and SLUG_CAP sets a per-article ceiling of 999,999. This won't stop determined attackers, but it's sufficient for a blog — adding authentication would hurt the casual click UX.

Why not use ISR or static generation for the like count?

Like counts need to be real-time. ISR (Incremental Static Regeneration) would show stale counts until the next revalidation. A client-side fetch on load is the only way to show the current number without a full page refresh.

Does the like button work with Vercel Edge Functions?

Yes, but it wouldn't save costs. Edge Functions have different pricing (per-invocation + CPU time), and the fundamental issue — a function invocation on every page load — remains the same. Edge Functions are faster (closer to the user) but don't reduce invocation count.

Is there a way to keep the like button without the cost issue on Vercel?

You could move the like count fetch to the server side during SSR, bundling it with the page render instead of firing a separate client-side request. This eliminates the extra Function Invocation but means the count is only as fresh as the page cache. Another option: use a third-party service like Firebase Realtime Database that handles the request outside Vercel's billing.


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.

Related articles: