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
| 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 @upstash/redis 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. If you're already dealing with SSR optimization challenges, adding another per-request function invocation compounds the problem.
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. 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: