Skip to content
rodolfo.gg
Go back

Cómo agregar un contador de visitas "de la vieja guardia" a un blog en Cloudflare con D1, para Astro y para SvelteKit en SSR.

CC BY-NC-ND 4.0
Rodolfo González González

Cómo agregar un contador de visitas "de la vieja guardia" a un blog en Cloudflare con D1, para Astro y para SvelteKit en SSR.

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:

En ambos casos el resultado tiene estas propiedades:


Tabla de contenido

Tabla de contenido

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:

  1. La página se renderiza normalmente (estática o SSR, no importa).
  2. Un pequeño script del lado cliente hace POST /api/views/<slug> al cargar.
  3. El endpoint, dentro del mismo Worker, ejecuta un UPSERT atómico en D1 y devuelve el nuevo total.
  4. 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:

RecursoLímite plan gratuitoLo que consume el contador
Workers / invocaciones100,000 solicitudes/día1 POST por carga de artículo
D1 lecturas de filas5,000,000 filas leídas/día1 fila por impresión
D1 escrituras de filas100,000 filas escritas/día1 fila por impresión
D1 almacenamiento5 GBuna 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:

Terminal window
npx wrangler d1 create views

Wrangler 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:

schema.sql
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):

Terminal window
npx wrangler d1 execute views --remote --file=./schema.sql

Y, si desarrollas en local con wrangler dev, también en la copia local:

Terminal window
npx wrangler d1 execute views --local --file=./schema.sql

Parte 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.):

wrangler.jsonc
{
"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:

Terminal window
npx wrangler types

Esto 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:

src/pages/api/views/[slug].ts
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:

src/components/ViewCounter.astro
---
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:

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:

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:

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:

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:

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:

estilos del contador (LED)
.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:

script del componente, render por dígitos
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:

casillas por dígito
.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.gif9.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:

Si aun así lo quieres por estética, el render coloca una <img> por dígito:

script del componente, dígitos como imágenes
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);
}
});
imágenes de dígitos
.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.gif9.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:


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íaComandoPermiso requerido
Crear la base de datoswrangler d1 create viewsAccount → D1 → Edit
Aplicar el esquema (remoto)wrangler d1 execute views --remote --file=./schema.sqlAccount → 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.

Captura de pantalla de la sección de permisos de un token de Cloudflare, mostrando "Account → D1 → Edit"

Crear el token

  1. Entra a My Profile → API Tokens y elige Create Token → Create Custom Token.
  2. En Permissions, añade una fila: Account · D1 · Edit.
  3. En Account Resources, selecciona Include → tu cuenta (no «All accounts»).
  4. Opcional pero recomendado: define una TTL (fecha de expiración) y, si tu red es fija, restringe por Client IP Address Filtering.
  5. 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):

Terminal window
export CLOUDFLARE_API_TOKEN="tu-token-acotado-a-D1-Edit"
export CLOUDFLARE_ACCOUNT_ID="tu-account-id"
npx wrangler d1 create views
npx wrangler d1 execute views --remote --file=./schema.sql

Referencias



Previous Post
Cómo configurar un sistema de audio 4.1 en Linux usando PipeWire, JamesDSP y systemd
Next Post
Glosario de investigación en inteligencia artificial.