Con un Route Handler de Next.js App Router y Upstash Redis, puedes implementar un botón de like sin autenticación en unos 30 minutos. El estado del cliente se maneja con localStorage y el debouncing optimiza las solicitudes.
Quería exactamente eso para mi blog. Clic y el contador sube — simple. Upstash Redis + API Routes lo hizo funcionar rápido. Pero después de desplegarlo en Vercel y ejecutarlo en producción, descubrí un problema de costos con las plataformas de facturación basada en uso y terminé eliminándolo.
Este artículo cubre el código completo de implementación y las lecciones de costos de ejecutarlo en una plataforma con facturación basada en uso.
Stack técnico
| Componente | Tecnología |
|---|---|
| Framework | Next.js 16 (App Router) |
| Estado | React useState + localStorage |
| Backend | Next.js API Route (Route Handler) |
| Almacén de datos | Upstash Redis |
| Hosting | Vercel |
Elegí Upstash Redis porque el plan gratuito incluye 500,000 comandos/mes y su API REST funciona perfectamente en entornos serverless.
Configurar Upstash Redis
- Crea una cuenta en Upstash Console
- Create Database → selecciona una región (
us-east-1para Norteamérica,ap-northeast-1para Japón) - Copia UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN
Agrégalos a .env.local en tu proyecto Next.js.
UPSTASH_REDIS_REST_URL=https://xxxxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=AxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxE=
Instala el SDK @upstash/redis.
npm install @upstash/redis
Crea un helper para el cliente Redis.
// 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() lee UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN de las variables de entorno automáticamente. El patrón singleton reutiliza la conexión en entornos serverless.
Crear la API Route
Un único Route Handler maneja tanto la consulta del conteo (GET) como el incremento (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 });
}
}
Puntos clave:
MAX_BATCH = 32— acepta hasta 32 likes por solicitud, usado con debouncing del lado del clienteSLUG_CAP— límite por artículo para defenderse contra abusoparamses unaPromise— desde Next.js 15, los params de rutas dinámicas son asíncronos
Crear el componente LikeButton
Es un componente de cliente.
// 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(() => {});
}, []);
// Enviar al salir de la página
useEffect(() => {
const onUnload = () => flush();
window.addEventListener("beforeunload", onUnload);
return () => {
window.removeEventListener("beforeunload", onUnload);
flush();
};
}, [flush]);
// Carga inicial: localStorage para conteo del usuario, servidor para 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: acumular clics, enviar una vez después de 3s de inactividad
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>
);
}
Decisiones de diseño
Sin autenticación, límites basados en localStorage. Cada usuario puede presionar hasta 32 veces. Limpiar localStorage reinicia el conteo, pero la estricta aplicación no vale el costo en UX para un botón de like de blog.
Debouncing (3 segundos). Los clics rápidos se agrupan en una sola solicitud. Hacer clic 10 veces resulta en un solo POST con count: 10 después de 3 segundos de inactividad.
GET en cada carga de página. Cada vez que se abre una página, se dispara una solicitud GET a /api/likes/[slug] para obtener el total actual. Esto se convierte en el problema de costos que se describe a continuación. Si ya estás lidiando con desafíos de optimización de SSR, agregar otra invocación de función por solicitud agrava el problema.
Agregarlo a la página de artículos
// app/[locale]/[category]/[slug]/page.tsx
import { LikeButton } from "@/components/LikeButton";
// ... dentro del componente de página
<article>
{/* Contenido del artículo */}
</article>
<aside>
<LikeButton slug={slug} />
</aside>
Eso es todo. El conteo de likes aparece al cargar y los clics lo incrementan.
Qué pasó en producción
Ejecuté este botón de like en un sitio con 108 artículos × 3 idiomas (324 páginas) desplegado en Vercel Pro.
El mecanismo
Cada carga de página dispara una solicitud GET del lado del cliente a la API Route /api/likes/[slug]. Esta API Route se ejecuta como una Serverless Function.
En Vercel, la ejecución de Serverless Functions se factura como tiempo de Fluid Active CPU. Cada solicitud es ligera (pocos milisegundos), pero las Function Invocations se acumulan proporcionalmente al número de solicitudes.
Cálculo de costos
Usando los precios de Vercel Pro:
Function Invocations: 1M/mes incluidas, luego $0.60/1M
Fluid Active CPU: $0.128/hora-CPU+ (compensado por crédito de $20)
Para un sitio con 10,000 páginas vistas mensuales:
- Solicitudes GET: 10,000/mes (una por carga de página)
- Solicitudes POST: ~500/mes (asumiendo que el 5% de visitantes hace clic)
- Total: ~10,500 Function Invocations/mes
Con 10K PV/mes, esto cabe cómodamente dentro de las 1M invocaciones gratuitas. Pero esto es solo el botón de like. Se suma al total de SSR, Middleware y otras API Routes del sitio.
Lo que realmente pasó
En mi sitio, múltiples factores se combinaron para alcanzar el límite de Fluid Active CPU de 4 horas/mes en el plan Hobby de Vercel.
El desglose:
- SSR (renderizado del lado del servidor de páginas de artículos): mayor consumidor
- Middleware (detección de idioma con next-intl, se ejecuta en cada solicitud): 14.1%
- API Route (GET del botón de like): desconocido (el dashboard de Vercel no muestra desglose de CPU por ruta)
- Bots accediendo a URLs inexistentes → activando SSR
El botón de like no fue la única causa. Pero una función poco utilizada que dispara una Serverless Function en cada carga de página es un costo desperdiciado en un entorno de facturación basada en uso.
Esto no aplica a VPS
Este problema es específico de plataformas con facturación basada en uso (Vercel, AWS Lambda, Cloudflare Workers, etc.).
En un VPS de costo fijo mensual, el servidor funciona 24/7 independientemente del número de solicitudes. Una API Route manejando 10,000 solicitudes no cuesta nada extra — todo está incluido en la tarifa mensual fija.
| Plataforma | 10K llamadas a API de likes/mes | Costo adicional |
|---|---|---|
| VPS (mensual fijo) | El servidor lo procesa | $0 (incluido) |
| Vercel Pro | 10K Serverless Functions | $0 (dentro del millón gratuito) |
| Vercel Hobby | 10K Serverless Functions | Cuenta para el límite de CPU |
En Vercel Pro, el millón de invocaciones gratuitas absorbe el costo directo, pero el tiempo de CPU aún consume el crédito mensual de $20. Si otras funciones (SSR, Middleware) agotan el crédito, se activa la facturación basada en uso.
Por qué lo eliminé
Terminé eliminando el botón de like de producción. Las razones:
- Bajo uso. Ubicado debajo de la tabla de contenidos en la barra lateral, casi nadie hacía clic
- GET se dispara en cada carga de página. Sin importar si alguien hace clic, cada visitante activaba una Serverless Function
- El presupuesto de CPU se aprovechaba mejor en otro lugar. SSR y Middleware impactan directamente el SEO — el botón de like no. Aplicar técnicas de optimización de build tenía mejor ROI
La función funcionaba correctamente. Fue una decisión de eficiencia de costos en un entorno de facturación basada en uso.
Preguntas frecuentes
¿Upstash Redis funciona con Next.js App Router?
Sí. Upstash Redis ofrece un SDK basado en REST que funciona en cualquier entorno serverless, incluyendo los Route Handlers de Next.js App Router. Al usar HTTP en lugar de conexiones TCP persistentes, no hay problemas de connection pooling en funciones serverless.
¿Cuánto cuesta Upstash Redis para un botón de like?
El plan gratuito incluye 500,000 comandos/mes. Un botón de like con 10,000 páginas vistas mensuales usaría aproximadamente 10,500 comandos (GET en cada carga + POSTs ocasionales), bien dentro del límite gratuito. El problema de costos viene de las invocaciones de Serverless Functions en Vercel, no de Upstash.
¿Puedo usar una base de datos en vez de Redis para el conteo de likes?
Puedes, pero Redis es más adecuado. Los conteos de likes son incrementos simples de enteros — el comando INCRBY de Redis maneja esto atómicamente en microsegundos. Una base de datos relacional funcionaría pero añade latencia innecesaria y complejidad de gestión de conexiones en entornos serverless.
¿Cómo prevenir abuso del botón de like sin autenticación?
La implementación usa tres capas de defensa: localStorage limita cada navegador a 32 clics, MAX_BATCH limita cada solicitud a 32 incrementos, y SLUG_CAP establece un techo de 999,999 por artículo. No detendrá atacantes determinados, pero es suficiente para un blog — agregar autenticación perjudicaría la experiencia de clic casual.
¿Por qué no usar ISR o generación estática para el conteo de likes?
Los conteos de likes necesitan ser en tiempo real. ISR (Incremental Static Regeneration) mostraría conteos obsoletos hasta la siguiente revalidación. Un fetch del lado del cliente en la carga es la única forma de mostrar el número actual sin un refresco completo de página.
¿Funciona el botón de like con Vercel Edge Functions?
Sí, pero no ahorraría costos. Las Edge Functions tienen precios diferentes (por invocación + tiempo de CPU), y el problema fundamental — una invocación de función en cada carga de página — sigue siendo el mismo. Son más rápidas (más cerca del usuario) pero no reducen el conteo de invocaciones.
¿Hay forma de mantener el botón de like sin el problema de costos en Vercel?
Podrías mover la consulta del conteo al servidor durante el SSR, agrupándola con el renderizado de la página en lugar de disparar una solicitud separada del lado del cliente. Esto elimina la Function Invocation extra pero el conteo solo será tan fresco como el caché de la página. Otra opción: usar un servicio externo como Firebase Realtime Database que maneja la solicitud fuera de la facturación de Vercel.
Conclusión
La implementación del botón de like en sí es sencilla. Upstash Redis + API Routes lo pone en funcionamiento en 30 minutos. El debouncing y localStorage lo hacen funcional sin autenticación.
En plataformas con facturación basada en uso como Vercel, sin embargo, "ligero por solicitud × cada página × cada visitante" se acumula. Al agregar funciones, evalúa el conteo total de solicitudes mensuales, no el costo por solicitud.
Si alojas en un VPS, nada de esto es una preocupación.
Para una guía detallada sobre la facturación basada en uso de Vercel y cómo protegerte, consulta Guía de Spend Management de Vercel.
Artículos relacionados: