Introducción
Hubo una época, antes de Google Analytics y antes de los dashboards de producto, en la que casi cualquier sitio web mostraba con orgullo un pequeño contador al pie de la página: “Eres la visita número 12,345”. Aquellos contadores de la vieja guardia no rastreaban personas, no perfilaban audiencias y no enviaban datos a terceros. Solo contaban impresiones y mostraban un número honesto.
Esta guía describe cómo recrear esa idea para un blog moderno desplegado en Cloudflare: un contador de impresiones o vistas por artículo, persistido en una base de datos, sin servidor aparte, sin costo en la práctica y sin sacrificar la privacidad del lector.
El almacenamiento será Cloudflare D1, una base de datos SQLite administrada que corre en el edge y se conecta a tu Worker mediante un binding. Se eligió D1 en lugar de KV debido a que KV no tiene incremento atómico, así que bajo tráfico concurrente perdería cuentas. D1 hace count = count + 1 de forma atómica en una sola sentencia SQL.
La guía cubre dos implementaciones del mismo patrón:
- Astro (concretamente un blog tipo AstroPaper) con el adapter
@astrojs/cloudflareyoutput: 'server'. - SvelteKit con el adapter
@sveltejs/adapter-cloudflare, corriendo también en SSR.
En ambos casos el resultado tiene estas propiedades:
- El contador vive en un endpoint propio del mismo proyecto; no hay servidor ni despliegue aparte.
- El sitio no necesita SSR adicional: si ya corres con un adapter de Cloudflare, el endpoint reutiliza ese mismo Worker.
- No se rastrea al usuario, no hay cookies de terceros ni IDs persistentes; solo se cuenta una impresión.
- El incremento es atómico, así que la cuenta no se corrompe bajo concurrencia.
- El costo, para un blog personal, es efectivamente cero.
Tabla de contenido
Tabla de contenido
- Arquitectura general
- Supuestos previos
- ¿Tiene costo?
- Crear la base de datos D1
- Parte 1: Astro (AstroPaper) en SSR con el adapter de Cloudflare
- Parte 2: SvelteKit con el adapter de Cloudflare en SSR
- Personalización gráfica opcional
- Variante opcional: no contar recargas del mismo visitante
- Endurecimiento básico
- Troubleshooting
- Conclusión
- Apéndice: Permisos del API token de Cloudflare
- Referencias
Arquitectura general
El patrón es idéntico en ambos frameworks. Cambia solo cómo el framework expone el binding de D1 al código del endpoint.
flowchart LR
READER["Navegador del lector"] -->|"POST /api/views/:slug"| WORKER["Worker en el edge (Astro o SvelteKit con adapter Cloudflare)"]
WORKER -->|"binding DB"| D1[("Cloudflare D1 SQLite")]
D1 -->|"count actualizado"| WORKER
WORKER -->|"JSON { count }"| READER
READER -->|"render"| DOM["12,345 vistas"]
El flujo en cada carga de artículo:
- La página se renderiza normalmente (estática o SSR, no importa).
- Un pequeño script del lado cliente hace
POST /api/views/<slug>al cargar. - El endpoint, dentro del mismo Worker, ejecuta un
UPSERTatómico en D1 y devuelve el nuevo total. - El script escribe el número en el DOM.
Supuestos previos
Usa esta checklist antes de implementar:
- Tienes un blog desplegado en Cloudflare Workers (o Pages) con
Astro +
@astrojs/cloudflare, o con SvelteKit +@sveltejs/adapter-cloudflare. - Tienes instalado y autenticado Wrangler (
npx wrangler login). - Tu proyecto ya despliega correctamente antes de agregar el contador.
- Conoces el slug o identificador único de cada artículo, disponible en tiempo de render (frontmatter, parámetro de ruta, etc.).
- Aceptas que la cuenta inicial empieza en cero; no hay datos históricos
que importar (si los tienes, puedes precargarlos con un
INSERT). - Estás de acuerdo con contar impresiones, no usuarios únicos, salvo que apliques la deduplicación opcional descrita más abajo.
¿Tiene costo?
Para un blog personal, no. Los límites del plan gratuito de Cloudflare están muy por encima de lo que un contador de visitas consume:
| Recurso | Límite plan gratuito | Lo que consume el contador |
|---|---|---|
| Workers / invocaciones | 100,000 solicitudes/día | 1 POST por carga de artículo |
| D1 lecturas de filas | 5,000,000 filas leídas/día | 1 fila por impresión |
| D1 escrituras de filas | 100,000 filas escritas/día | 1 fila por impresión |
| D1 almacenamiento | 5 GB | una fila diminuta por artículo |
Una sola fila por artículo (slug + entero) pesa bytes. Incluso un blog con decenas de miles de visitas diarias se queda holgadamente dentro del plan gratuito. Si algún día lo superas, el plan de pago de D1 cobra por filas leídas/escritas y por GB-mes, en montos centavos para este caso de uso.
Crear la base de datos D1
Este paso es idéntico para Astro y para SvelteKit. La tabla es la misma.
Crea la base de datos:
npx wrangler d1 create viewsWrangler imprime un bloque con el database_id. Guárdalo; lo necesitarás en el wrangler.jsonc.
Crea la tabla. Conviene tenerla en un archivo de esquema versionado:
CREATE TABLE IF NOT EXISTS views ( slug TEXT PRIMARY KEY, count INTEGER NOT NULL DEFAULT 0);Aplícala, primero en remoto (la base real que usa tu Worker desplegado):
npx wrangler d1 execute views --remote --file=./schema.sqlY, si desarrollas en local con wrangler dev, también en la copia local:
npx wrangler d1 execute views --local --file=./schema.sqlParte 1: Astro (AstroPaper) en SSR con el adapter de Cloudflare
Esta parte asume un blog tipo AstroPaper con output: 'server' y adapter: cloudflare().
Declarar el binding de D1 en wrangler
Agrega el binding DB a tu wrangler.jsonc (junto a tu assets, compatibility_date, etc.):
{ "name": "mi-blog", "compatibility_date": "2026-03-06", "compatibility_flags": ["nodejs_compat"], "assets": { "binding": "ASSETS", "directory": "./dist" }, // [!code ++] "d1_databases": [ // [!code ++] { // [!code ++] "binding": "DB", // [!code ++] "database_name": "views", // [!code ++] "database_id": "PEGA-AQUI-EL-DATABASE-ID" // [!code ++] } // [!code ++] ]}Tipar el binding para TypeScript
En Astro 6 (adapter @astrojs/cloudflare 13.x), genera los tipos a partir de los bindings declarados en wrangler.jsonc:
npx wrangler typesEsto crea un worker-configuration.d.ts con la interfaz global Env (que ya incluye tu DB) y las definiciones del módulo cloudflare:workers. El archivo lo recoge tu tsconfig.json por el patrón include habitual.
El endpoint del contador
Crea src/pages/api/views/[slug].ts. Un solo POST incrementa y devuelve el total:
import type { APIRoute } from "astro";import { env } from "cloudflare:workers";
// El sitio puede ser estático; este endpoint debe ser dinámico.export const prerender = false;
export const POST: APIRoute = async ({ params }) => { const slug = params.slug; if (!slug) { return new Response("Missing slug", { status: 400 }); }
const db = env.DB;
// UPSERT atómico: inserta con 1, o suma 1 si ya existe. await db .prepare( "INSERT INTO views (slug, count) VALUES (?, 1) " + "ON CONFLICT(slug) DO UPDATE SET count = count + 1" ) .bind(slug) .run();
const row = await db .prepare("SELECT count FROM views WHERE slug = ?") .bind(slug) .first<{ count: number }>();
return Response.json({ count: row?.count ?? 0 });};
// Opcional: leer sin incrementar (p. ej. para una página de estadísticas).export const GET: APIRoute = async ({ params }) => { const slug = params.slug; if (!slug) return new Response("Missing slug", { status: 400 });
const row = await env.DB .prepare("SELECT count FROM views WHERE slug = ?") .bind(slug) .first<{ count: number }>();
return Response.json({ count: row?.count ?? 0 });};El componente visual
Crea un componente reutilizable src/components/ViewCounter.astro que reciba el slug y pinte el número:
---interface Props { slug: string;}const { slug } = Astro.props;---
<span class="view-counter" aria-live="polite"> <span id="view-count" data-slug={slug}>…</span> vistas</span>
<script> function loadViewCount() { const el = document.getElementById("view-count"); if (!el) return; const slug = el.dataset.slug; fetch(`/api/views/${slug}`, { method: "POST" }) .then((r) => r.json() as Promise<{ count: number }>) .then((d) => { el.textContent = new Intl.NumberFormat().format(d.count); }) .catch(() => { el.textContent = "—"; }); } // astro:page-load corre en la carga inicial y tras cada navegación con ClientRouter. document.addEventListener("astro:page-load", loadViewCount);</script>Úsalo en el layout del artículo (en AstroPaper, típicamente src/layouts/PostDetails.astro o equivalente), pasándole el slug del post:
---import ViewCounter from "@/components/ViewCounter.astro";// `post` es la entrada del artículo en tu layout.---
<ViewCounter slug={post.id} />Parte 2: SvelteKit con el adapter de Cloudflare en SSR
El patrón es el mismo; cambia la mecánica del framework. SvelteKit expone los bindings de Cloudflare a través de event.platform.env.
Adapter y binding
Asegúrate de usar @sveltejs/adapter-cloudflare en svelte.config.js:
import adapter from "@sveltejs/adapter-cloudflare";import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default { preprocess: vitePreprocess(), kit: { adapter: adapter(), },};Declara el binding DB igual que antes, en wrangler.jsonc:
{ "name": "mi-blog-sveltekit", "compatibility_date": "2026-03-06", "compatibility_flags": ["nodejs_compat"], "d1_databases": [ { "binding": "DB", "database_name": "views", "database_id": "PEGA-AQUI-EL-DATABASE-ID" } ]}Tipar la plataforma
En SvelteKit los bindings viven en App.Platform. Decláralos en src/app.d.ts:
declare global { namespace App { interface Platform { env: { DB: D1Database; }; } }}
export {};El endpoint del contador
Crea src/routes/api/views/[slug]/+server.ts:
import { json, error } from "@sveltejs/kit";import type { RequestHandler } from "./$types";
export const POST: RequestHandler = async ({ params, platform }) => { const db = platform?.env.DB; if (!db) throw error(500, "D1 binding no disponible");
const slug = params.slug;
await db .prepare( "INSERT INTO views (slug, count) VALUES (?, 1) " + "ON CONFLICT(slug) DO UPDATE SET count = count + 1" ) .bind(slug) .run();
const row = await db .prepare("SELECT count FROM views WHERE slug = ?") .bind(slug) .first<{ count: number }>();
return json({ count: row?.count ?? 0 });};
export const GET: RequestHandler = async ({ params, platform }) => { const db = platform?.env.DB; if (!db) throw error(500, "D1 binding no disponible");
const row = await db .prepare("SELECT count FROM views WHERE slug = ?") .bind(params.slug) .first<{ count: number }>();
return json({ count: row?.count ?? 0 });};El componente visual
Crea src/lib/components/ViewCounter.svelte:
<script lang="ts"> import { onMount } from "svelte";
export let slug: string;
let count: number | null = null;
onMount(async () => { try { const r = await fetch(`/api/views/${slug}`, { method: "POST" }); const d = (await r.json()) as { count: number }; count = d.count; } catch { count = null; } });</script>
<span aria-live="polite"> {count === null ? "—" : new Intl.NumberFormat().format(count)} vistas</span>Úsalo en la página del artículo (src/routes/blog/[slug]/+page.svelte o donde renderices el post):
<script lang="ts"> import ViewCounter from "$lib/components/ViewCounter.svelte"; export let data; // viene de tu +page.ts / load</script>
<ViewCounter slug={data.post.slug} />Personalización gráfica opcional
El número honesto ya cumple, pero parte del encanto de los contadores de la vieja guardia era su aspecto: dígitos blancos sobre fondo negro, estilo display LED o marcador mecánico. Hay dos caminos para lograrlo.
Opción A (recomendada): estilo LED con CSS
Da al contador un aspecto de display digital con una fuente monoespaciada, fondo oscuro y un leve resplandor. No cambia el componente: solo añade estilos. Sobre el <span class="view-counter"> del componente Astro (o el <span> del componente Svelte), agrega:
.view-counter { display: inline-flex; align-items: center; gap: 0.35em; font-family: "Courier New", ui-monospace, monospace; font-weight: 700; letter-spacing: 0.08em; padding: 0.15em 0.5em; border-radius: 0.25em; background: #000; color: #00ff6a; /* verde fósforo clásico */ text-shadow: 0 0 6px #00ff6a; /* resplandor LED */}Para el clásico blanco sobre negro del anexo, cambia color: #fff y elimina o ajusta el text-shadow.
Opción A+: cada dígito en su propia “casilla”
El look de marcador mecánico (cada cifra en su recuadro negro) requiere envolver cada dígito en su propio elemento. Modifica el render para construir un <span> por dígito en lugar de escribir el número plano. En el componente Astro:
fetch(`/api/views/${slug}`, { method: "POST" }) .then((r) => r.json() as Promise<{ count: number }>) .then((d) => { const digits = new Intl.NumberFormat().format(d.count); el.textContent = ""; for (const ch of digits) { const span = document.createElement("span"); span.className = /\d/.test(ch) ? "digit" : "sep"; span.textContent = ch; el.appendChild(span); } });Con su CSS:
.view-counter :global(.digit) { display: inline-block; min-width: 1ch; padding: 0.1em 0.25em; margin: 0 1px; text-align: center; background: #111; color: #fff; border-radius: 3px; box-shadow: inset 0 0 0 1px #333;}.view-counter :global(.sep) { /* la coma de los miles */ padding: 0 0.1em; color: #888;}Opción B: imágenes con los dígitos (el método retro)
Es el método original de los noventa: una imagen por cifra (0.gif … 9.gif), o un único sprite con los diez dígitos, y se compone el número colocando la imagen correcta para cada cifra. Reproduce un pixel-art exacto que CSS no siempre puede imitar, pero a cambio:
- Cada dígito es una imagen: no escala nítido en pantallas de alta densidad salvo que prepares versiones @2x.
- El número deja de ser texto seleccionable; debes poner un
alt/aria-labelcon el valor para no perder accesibilidad. - Añade peticiones HTTP y sprites que mantener y precargar.
Si aun así lo quieres por estética, el render coloca una <img> por dígito:
fetch(`/api/views/${slug}`, { method: "POST" }) .then((r) => r.json() as Promise<{ count: number }>) .then((d) => { const n = String(d.count); // sin separadores, más simple el.textContent = ""; el.setAttribute("aria-label", `${n} vistas`); for (const ch of n) { const img = document.createElement("img"); img.src = `/counter/${ch}.gif`; // 0.gif … 9.gif en /public/counter/ img.alt = ""; // decorativo; el aria-label lleva el valor img.className = "digit-img"; el.appendChild(img); } });.view-counter :global(.digit-img) { height: 1.2em; width: auto; image-rendering: pixelated; /* conserva el filo pixel-art al escalar */ vertical-align: middle;}Coloca tus 0.gif…9.gif en public/counter/ (Astro) o static/counter/ (SvelteKit) para que se sirvan como assets estáticos.
Variante opcional: no contar recargas del mismo visitante
El diseño base cuenta cada impresión, incluidas recargas. Para acercarte más a “vistas” sin rastrear al usuario, marca en localStorage que este navegador ya contó este artículo. Es deduplicación puramente local: no hay cookies, ni IDs, ni datos enviados a terceros.
Reemplaza la llamada fetch por:
const key = `viewed:${slug}`;if (!localStorage.getItem(key)) { localStorage.setItem(key, "1"); fetch(`/api/views/${slug}`, { method: "POST" }) .then((r) => r.json()) .then((d) => { /* pintar d.count */ });} else { // Ya contó antes: solo leer el total sin incrementar. fetch(`/api/views/${slug}`) .then((r) => r.json()) .then((d) => { /* pintar d.count */ });}Endurecimiento básico
Algunas medidas opcionales, en orden de utilidad real para un blog:
- Filtrar bots evidentes. Como la cuenta la dispara JavaScript del cliente, la mayoría de los crawlers simples ya no la activan (no ejecutan el script). Para los que sí, puedes revisar
User-Agenten el endpoint y omitir el incremento de patrones conocidos de bots. No esperes perfección. - Validar el slug. Acepta solo slugs que existan realmente, para que nadie infle filas basura con
POST /api/views/lo-que-sea. Si tienes una lista de slugs publicados, recházalos si no están en ella; o restringe el formato con una expresión regular sencilla. - Rate limiting. Para abuso deliberado, Cloudflare ofrece reglas de rate limiting en el edge sobre la ruta
/api/views/*, sin tocar tu código. - Reducir escrituras a escala. Si algún día el volumen lo amerita, deduplica por cliente (sección anterior) o acumula incrementos en memoria/KV y vuelca a D1 por lotes. Es optimización prematura para casi cualquier blog; no la hagas hasta medir que hace falta.
Troubleshooting
El número aparece como ”—” o no cambia.
Abre la consola del navegador y la pestaña de red. Verifica que POST /api/views/<slug> responde 200 con { "count": N }. Un 500 suele significar que el binding DB no llegó al endpoint.
env.DB is undefined (Astro) o platform is undefined (SvelteKit).
El binding no está conectado. Revisa que d1_databases esté en wrangler.jsonc con el database_id correcto, que el nombre del binding coincida con el que usas en el código (DB), que hayas redeployado, y —en desarrollo— que estés corriendo con el adapter de Cloudflare (no un dev sin emulación de plataforma).
La propiedad 'runtime' no existe en el tipo 'Locals' (Astro).
Estás usando el patrón viejo Astro.locals.runtime.env, eliminado en Astro 6. Importa env de cloudflare:workers (ver el endpoint) y genera los tipos con npx wrangler types.
no such table: views.
No aplicaste el esquema en el entorno que estás golpeando. Recuerda que --local y --remote son bases distintas: ejecuta el schema.sql en ambas según corresponda.
Funciona en local pero no en producción (o viceversa).
Casi siempre es el mismo problema de --local vs --remote. La base local de wrangler dev no es la base que usa tu Worker desplegado.
El conteo sube de dos en dos.
En desarrollo, el Strict Mode de React (si lo usas en islas Astro) o un doble montaje pueden disparar dos fetch. En producción no ocurre; si te molesta en dev, usa la variante con localStorage.
Conclusión
Recrear el contador de visitas de la vieja guardia sobre Cloudflare es sorprendentemente directo: una tabla D1 con una fila por artículo, un endpoint que hace un UPSERT atómico y un script de cliente de diez líneas. No hace falta un servidor aparte, no hace falta activar SSR solo para esto, y el costo es cero en la práctica.
El mismo patrón sirve para Astro y para SvelteKit; lo único que cambia es cómo cada framework te entrega el binding de D1: import { env } from "cloudflare:workers" en Astro 6, platform.env en SvelteKit. La lógica de base de datos —la sentencia SQL, el incremento atómico, la lectura del total— es exactamente la misma en ambos.
Y, fiel al espíritu original, el contador cuenta impresiones de forma honesta y sin rastrear a nadie: un número simple al pie del artículo, como en los viejos tiempos.
Apéndice: Permisos del API token de Cloudflare
Las operaciones de esta guía —crear la base de datos D1 y aplicar el esquema con Wrangler— solo tocan D1. No necesitan permisos de Workers, DNS ni del resto de la cuenta.
Si usas wrangler login (flujo OAuth interactivo), Wrangler obtiene los permisos por ti y no necesitas crear un token. Este apéndice aplica cuando prefieres un API token explícito, por ejemplo para correr Wrangler en un entorno no interactivo o con credenciales acotadas.
Permiso mínimo necesario
La operación es similar a la descrita en este post sobre despliegue de SvelteKit en Cloudflare.
Para D1, el permiso mínimo es:
| Operación de la guía | Comando | Permiso requerido |
|---|---|---|
| Crear la base de datos | wrangler d1 create views | Account → D1 → Edit |
| Aplicar el esquema (remoto) | wrangler d1 execute views --remote --file=./schema.sql | Account → D1 → Edit |
| Precargar/consultar filas (remoto) | wrangler d1 execute ... --command "..." | Account → D1 → Edit |
Con eso basta. Edit incluye lectura y escritura sobre D1; no requieres un permiso de lectura aparte. Las operaciones --local no tocan la API de Cloudflare (corren contra una copia local en disco), así que no consumen permisos del token.

Crear el token
- Entra a My Profile → API Tokens y elige Create Token → Create Custom Token.
- En Permissions, añade una fila:
Account·D1·Edit. - En Account Resources, selecciona Include → tu cuenta (no «All accounts»).
- Opcional pero recomendado: define una TTL (fecha de expiración) y, si tu red es fija, restringe por Client IP Address Filtering.
- Crea el token y cópialo una sola vez; Cloudflare no lo vuelve a mostrar.
Usar el token con Wrangler
Wrangler lee el token de la variable de entorno CLOUDFLARE_API_TOKEN. Para los comandos de D1 conviene además fijar el CLOUDFLARE_ACCOUNT_ID (lo ves en el panel de tu cuenta):
export CLOUDFLARE_API_TOKEN="tu-token-acotado-a-D1-Edit"export CLOUDFLARE_ACCOUNT_ID="tu-account-id"
npx wrangler d1 create viewsnpx wrangler d1 execute views --remote --file=./schema.sqlReferencias
- Cloudflare. (2026). D1: Cloudflare’s native serverless SQL database. Cloudflare Docs. https://developers.cloudflare.com/d1/
- Cloudflare. (2026). D1 Workers Binding API (prepare, bind, run, first). Cloudflare Docs. https://developers.cloudflare.com/d1/worker-api/
- Cloudflare. (2026). Wrangler commands: d1 create / d1 execute. Cloudflare Docs. https://developers.cloudflare.com/workers/wrangler/commands/#d1
- Cloudflare. (2026). Create API token and token permissions. Cloudflare Docs. https://developers.cloudflare.com/fundamentals/api/get-started/create-token/
- Cloudflare. (2026). Pricing: Workers & D1 free tier limits. Cloudflare Docs. https://developers.cloudflare.com/d1/platform/pricing/
- Astro. (2026). Cloudflare adapter (@astrojs/cloudflare): accessing the runtime. Astro Docs. https://docs.astro.build/en/guides/integrations-guide/cloudflare/
- Astro. (2026). Endpoints and on-demand rendering (prerender). Astro Docs. https://docs.astro.build/en/guides/endpoints/
- SvelteKit. (2026). adapter-cloudflare and platform.env bindings. Svelte Docs. https://svelte.dev/docs/kit/adapter-cloudflare
- SvelteKit. (2026). Server routes (+server.js). Svelte Docs. https://svelte.dev/docs/kit/routing#server
- SQLite. (2026). UPSERT (ON CONFLICT … DO UPDATE). SQLite Documentation. https://www.sqlite.org/lang_upsert.html
