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
| Component | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| State | React useState + localStorage |
| Backend | Next.js API Route (Route Handler) |
| Data store | Upstash Redis |
| Hosting | Vercel |
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
- Create an account at Upstash Console
- Create Database → select a region (
us-east-1for North America,ap-northeast-1for Japan) - Copy the UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN
Add them to .env.local in your Next.js project.
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=
Install the SDK.
npm install @upstash/redis
Create a Redis client helper.
// 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).
// 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 debouncingSLUG_CAP— per-article cap to defend against abuseparamsis aPromise— since Next.js 15, dynamic route params are async
Building the LikeButton Component
This is a client component.
// 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
// 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.
| Platform | 10K like API calls/month | Additional cost |
|---|---|---|
| VPS (fixed monthly) | Server handles it | $0 (included) |
| Vercel Pro | 10K Serverless Functions | $0 (within 1M free tier) |
| Vercel Hobby | 10K Serverless Functions | Counts 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:
- Low usage. Placed below the sidebar table of contents, almost nobody clicked it
- GET fires on every page load. Even with zero clicks, every visitor triggered a Serverless Function
- 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.