Métricas ao vivo sem Google Analytics
Como implementamos a contagem de page views e visitantes únicos nesta plataforma: ViewTracker no client, APIs de track e leitura, Redis com HyperLogLog e Sorted Set, rate limiting e filtro de bots. Tudo que o dev precisa para replicar.
Sistema leve e privacy-friendly: nenhum cookie, IP apenas em hash, dados agregados em tempo real. Ideal para portfolio ou site que queira mostrar números reais sem depender de terceiros.
Total de visualizações (INCR) e visitantes únicos via HyperLogLog no Redis.
REST API serverless, free tier generoso. Compatível com Vercel e edge.
Filtro de bots, rate limiting, IP hasheado com salt. Nenhum dado pessoal armazenado.
Pipeline Redis (1 round-trip), cache in-memory e CDN. sendBeacon no client.
Do clique do usuário até os números na tela: como o dev implementa passo a passo.
Componente client que usa usePathname e useEffect. A cada mudança de path, envia POST para /api/stats/track com { path }. Usa sessionStorage para não contar duas vezes a mesma página na mesma sessão.
Valida body com Zod (path string 1–200 chars). Filtra bots por User-Agent. Aplica rate limit por IP. Faz hash do IP com SHA256 + salt (token Redis). Executa pipeline: INCR stats:views, PFADD stats:visitors, ZINCRBY stats:pages.
Rate limit de leitura. Cache in-memory 60s. Se miss, pipeline Redis: GET views, PFCOUNT visitors, ZRANGE pages 0 4 REV WITHSCORES. Monta JSON e retorna com Cache-Control para CDN.
Hook que dá fetch em /api/stats, expõe stats, loading, error e retry. Hero e página Contribua consomem o hook e exibem visitantes, visualizações e top 5 páginas.
Endpoint POST que recebe o path da página e persiste no Redis. Bots são ignorados; IP vira hash para privacidade.
// POST /api/stats/trackimport { createHash } from "node:crypto";import { z } from "zod";import { redis } from "@/lib/redis";import { getClientIp, rateLimit, rateLimitResponse } from "@/lib/rate-limit";const BOT_PATTERN = /bot|crawl|spider|slurp|.../i;const bodySchema = z.object({ path: z.string().min(1).max(200) });function isBot(request: Request): boolean {const ua = request.headers.get("user-agent") ?? "";return !ua || ua.length < 10 || BOT_PATTERN.test(ua);}export async function POST(request: Request) {if (!redis) return Response.json({ ok: true });if (isBot(request)) return Response.json({ ok: true });const ip = getClientIp(request);const rl = rateLimit(ip, { prefix: "stats-track", limit: 60, windowSeconds: 60 });if (!rl.success) return rateLimitResponse(rl);const body = await request.json().catch(() => null);const parsed = bodySchema.safeParse(body);if (!parsed.success) return Response.json({ error: "Invalid" }, { status: 400 });const ipHash = createHash("sha256").update(ip + process.env.UPSTASH_REDIS_REST_TOKEN).digest("hex").slice(0, 16);const pipeline = redis.pipeline();pipeline.incr("stats:views");pipeline.pfadd("stats:visitors", ipHash);pipeline.zincrby("stats:pages", 1, parsed.data.path);await pipeline.exec();return Response.json({ ok: true });}
Filtro de bots por regex no User-Agent (Google, Bing, Selenium, etc.).
Rate limit: 60 req/min por IP para evitar abuso.
Zod valida path (obrigatório, max 200 caracteres).
IP é hasheado com SHA256(ip + REDIS_TOKEN).slice(0,16) — não armazena IP real.
Pipeline: uma única chamada de rede para INCR, PFADD e ZINCRBY.
GET que retorna views, visitors e top 5 páginas. Cache em memória (60s) e headers para CDN reduzem carga no Redis.
// GET /api/statsconst CACHE_TTL = 60_000;let cache = { data: null, updatedAt: 0 };export async function GET(request: Request) {if (!redis) return Response.json(EMPTY_STATS, { headers: { "Cache-Control": "public, s-maxage=60" } });const rl = rateLimit(ip, { prefix: "stats-read", limit: 30, windowSeconds: 60 });if (!rl.success) return rateLimitResponse(rl);if (cache.data && Date.now() - cache.updatedAt < CACHE_TTL) {return Response.json(cache.data, { headers: { "X-Cache": "HIT", ... } });}const pipeline = redis.pipeline();pipeline.get("stats:views");pipeline.pfcount("stats:visitors");pipeline.zrange("stats:pages", 0, 4, { rev: true, withScores: true });const results = await pipeline.exec();const data = { views, visitors, topPages };cache = { data, updatedAt: Date.now() };return Response.json(data, { headers: { "X-Cache": "MISS", "Cache-Control": "..." } });}
Cache in-memory com TTL 60s — várias requisições servidas sem bater no Redis.
Cache-Control: s-maxage=60, stale-while-revalidate=300 para edge.
Em erro, retorna cache antigo (X-Cache: STALE) se existir.
ZRANGE ... REV WITHSCORES devolve o ranking já ordenado; parsing no loop i += 2.
Upstash Redis via REST. Se as env vars não existirem, redis é null e as rotas fazem graceful degradation (retornam ok ou zeros).
import { Redis } from "@upstash/redis";const hasRedisConfig =process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN;export const redis = hasRedisConfig? new Redis({url: process.env.UPSTASH_REDIS_REST_URL!,token: process.env.UPSTASH_REDIS_REST_TOKEN!,}): null;
stats:viewsString (INCR)Contador global de page views. Cada track incrementa em 1.
stats:visitorsHyperLogLogEstimativa de visitantes únicos. PFADD com hash do IP; PFCOUNT retorna o cardinal aproximado (~12 KB fixos).
stats:pagesSorted SetRanking de páginas. Member = path, score = número de views. ZINCRBY no track; ZRANGE 0 4 REV WITHSCORES no GET.
ViewTracker dispara o tracking com sendBeacon (ou fetch keepalive). useSiteStats busca os dados para exibir na UI.
"use client";import { usePathname } from "next/navigation";import { useEffect } from "react";export function ViewTracker() {const pathname = usePathname();useEffect(() => {const path = pathname.replace(/^\/(pt-BR|en|es|de)/, "") || "/";const storageKey = `tracked:${path}`;if (sessionStorage.getItem(storageKey)) return;sessionStorage.setItem(storageKey, "1");const body = JSON.stringify({ path });if (navigator.sendBeacon) {navigator.sendBeacon("/api/stats/track", new Blob([body], { type: "application/json" }));} else {fetch("/api/stats/track", { method: "POST", headers: { "Content-Type": "application/json" }, body, keepalive: true }).catch(() => {});}}, [pathname]);return null;}
"use client";import { useCallback, useEffect, useState } from "react";export function useSiteStats() {const [stats, setStats] = useState({ views: 0, visitors: 0, topPages: [] });const [loading, setLoading] = useState(true);const [error, setError] = useState(false);const fetchStats = useCallback(async () => {setLoading(true);setError(false);try {const res = await fetch("/api/stats");if (!res.ok) throw new Error();setStats(await res.json());} catch {setError(true);} finally {setLoading(false);}}, []);useEffect(() => { fetchStats(); }, [fetchStats]);return { stats, loading, error, retry: fetchStats };}
sendBeacon não bloqueia a navegação e garante envio mesmo se o usuário fechar a aba.
sessionStorage evita contar a mesma página duas vezes na mesma sessão (por path).
Path normalizado: remove prefixo de locale (pt-BR, en, es, de) para manter keys consistentes.
Hook retorna loading, error e retry — UI pode mostrar skeleton, mensagem de erro ou botão tentar novamente.
Os mesmos dados que a API entrega: visitantes únicos, page views e top páginas. Demo ao vivo desta implementação.
Carregando métricas...
Passos para replicar a implementação no seu projeto.
As métricas estão na home (badges), na página Contribua e nesta página (seção Métricas ao vivo acima).