32blogby StudioMitsu

Cómo crear un botón de like con Next.js y Upstash Redis

Implementación paso a paso de un botón de like con Next.js App Router y Upstash Redis, más las lecciones de costos aprendidas al ejecutarlo en la facturación basada en uso de Vercel.

9 min read
Contenido

Quería un botón de like para mi blog. Sin autenticación, sin configurar base de datos, solo hacer clic y que el contador suba — simple.

Next.js App Router API Routes + Upstash Redis lo hizo funcionar en 30 minutos. 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

ComponenteTecnología
FrameworkNext.js 16 (App Router)
EstadoReact useState + localStorage
BackendNext.js API Route (Route Handler)
Almacén de datosUpstash Redis
HostingVercel

Elegí Upstash Redis porque el plan gratuito incluye 500,000 comandos/mes y su API REST funciona perfectamente en entornos serverless.


Configurar Upstash Redis

  1. Crea una cuenta en Upstash Console
  2. Create Database → selecciona una región (us-east-1 para Norteamérica, ap-northeast-1 para Japón)
  3. Copia UPSTASH_REDIS_REST_URL y UPSTASH_REDIS_REST_TOKEN

Agrégalos a .env.local en tu proyecto Next.js.

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

Instala el SDK.

bash
npm install @upstash/redis

Crea un helper para el cliente Redis.

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() 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).

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

Puntos clave:

  • MAX_BATCH = 32 — acepta hasta 32 likes por solicitud, usado con debouncing del lado del cliente
  • SLUG_CAP — límite por artículo para defenderse contra abuso
  • params es una Promise — desde Next.js 15, los params de rutas dinámicas son asíncronos

Crear el componente LikeButton

Es un componente de cliente.

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

  // 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.


Agregarlo a la página de artículos

tsx
// 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.

Plataforma10K llamadas a API de likes/mesCosto adicional
VPS (mensual fijo)El servidor lo procesa$0 (incluido)
Vercel Pro10K Serverless Functions$0 (dentro del millón gratuito)
Vercel Hobby10K Serverless FunctionsCuenta 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:

  1. Bajo uso. Ubicado debajo de la tabla de contenidos en la barra lateral, casi nadie hacía clic
  2. GET se dispara en cada carga de página. Sin importar si alguien hace clic, cada visitante activaba una Serverless Function
  3. El presupuesto de CPU se aprovechaba mejor en otro lugar. SSR y Middleware impactan directamente el SEO — el botón de like no

La función funcionaba correctamente. Fue una decisión de eficiencia de costos en un entorno de facturación basada en uso.


Resumen

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.