Skip to content
rodolfo.gg
Go back

Comment ajouter un compteur de visites « à l'ancienne » à un blog Cloudflare avec D1, pour Astro et SvelteKit en SSR.

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

Comment ajouter un compteur de visites « à l'ancienne » à un blog Cloudflare avec D1, pour Astro et SvelteKit en SSR.

Introduction

Il fut un temps, avant Google Analytics et avant les tableaux de bord produit, où presque n’importe quel site web affichait fièrement un petit compteur en bas de page : « Vous êtes le visiteur numéro 12 345 ». Ces compteurs à l’ancienne ne suivaient pas les personnes, ne profilaient pas les audiences et n’envoyaient pas de données à des tiers. Ils comptaient seulement les impressions et affichaient un nombre honnête.

Ce guide décrit comment recréer cette idée pour un blog moderne déployé sur Cloudflare : un compteur d’impressions ou de vues par article, persisté dans une base de données, sans serveur séparé, pratiquement sans coût et sans sacrifier la confidentialité du lecteur.

Le stockage sera Cloudflare D1, une base de données SQLite managée qui s’exécute à l’edge et se connecte à votre Worker au moyen d’un binding. D1 a été choisi à la place de KV, car KV ne dispose pas d’incrément atomique ; avec du trafic concurrent, il perdrait donc des comptes. D1 exécute count = count + 1 de manière atomique dans une seule instruction SQL.

Le guide couvre deux implémentations du même modèle :

Dans les deux cas, le résultat possède ces propriétés :


Table des matières

Table des matières

Architecture générale

Le modèle est identique dans les deux frameworks. Seule change la façon dont le framework expose le binding D1 au code de l’endpoint.

flowchart LR
    READER["Navigateur du lecteur"] -->|"POST /api/views/:slug"| WORKER["Worker à l'edge (Astro ou SvelteKit avec adapter Cloudflare)"]
    WORKER -->|"binding DB"| D1[("Cloudflare D1 SQLite")]
    D1 -->|"compte mis à jour"| WORKER
    WORKER -->|"JSON { count }"| READER
    READER -->|"rendu"| DOM["12 345 vues"]

Le flux à chaque chargement d’article :

  1. La page se rend normalement (statique ou SSR, peu importe).
  2. Un petit script côté client envoie POST /api/views/<slug> au chargement.
  3. L’endpoint, dans le même Worker, exécute un UPSERT atomique dans D1 et renvoie le nouveau total.
  4. Le script écrit le nombre dans le DOM.

Prérequis

Utilisez cette checklist avant d’implémenter :

  • Vous avez un blog déployé sur Cloudflare Workers (ou Pages) avec Astro + @astrojs/cloudflare, ou avec SvelteKit + @sveltejs/adapter-cloudflare. - [ ] Vous avez installé et authentifié Wrangler (npx wrangler login). - [ ] Votre projet se déploie déjà correctement avant d’ajouter le compteur. - [ ] Vous connaissez le slug ou l’identifiant unique de chaque article, disponible au moment du rendu (frontmatter, paramètre de route, etc.). - [ ] Vous acceptez que le compteur initial commence à zéro ; il n’y a pas de données historiques à importer (si vous en avez, vous pouvez les précharger avec un INSERT). - [ ] Vous êtes d’accord pour compter des impressions, pas des utilisateurs uniques, sauf si vous appliquez la déduplication optionnelle décrite plus bas.

Est-ce payant ?

Pour un blog personnel, non. Les limites du plan gratuit de Cloudflare sont très supérieures à ce que consomme un compteur de visites :

RessourceLimite du plan gratuitCe que consomme le compteur
Workers / invocations100 000 requêtes/jour1 POST par chargement d’article
Lectures de lignes D15 000 000 lignes lues/jour1 ligne par impression
Écritures de lignes D1100 000 lignes écrites/jour1 ligne par impression
Stockage D15 Goune minuscule ligne par article

Une seule ligne par article (slug + entier) pèse quelques octets. Même un blog avec des dizaines de milliers de visites quotidiennes reste largement dans le plan gratuit. Si vous le dépassez un jour, le plan payant de D1 facture les lignes lues/écrites et les Go-mois, pour des montants de l’ordre de quelques centimes dans ce cas d’usage.


Créer la base de données D1

Cette étape est identique pour Astro et SvelteKit. La table est la même.

Créez la base de données :

Terminal window
npx wrangler d1 create views

Wrangler affiche un bloc contenant le database_id. Conservez-le ; vous en aurez besoin dans wrangler.jsonc.

Créez la table. Il est préférable de la garder dans un fichier de schéma versionné :

schema.sql
CREATE TABLE IF NOT EXISTS views (
slug TEXT PRIMARY KEY,
count INTEGER NOT NULL DEFAULT 0
);

Appliquez-le d’abord à distance (la vraie base utilisée par votre Worker déployé) :

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

Et, si vous développez localement avec wrangler dev, appliquez-le aussi à la copie locale :

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

Partie 1 : Astro (AstroPaper) en SSR avec l’adapter Cloudflare

Cette partie suppose un blog de type AstroPaper avec output: 'server' et adapter: cloudflare().

Déclarer le binding D1 dans wrangler

Ajoutez le binding DB à votre wrangler.jsonc (avec assets, compatibility_date, etc.) :

wrangler.jsonc
{
"name": "mon-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": "COLLEZ-ICI-LE-DATABASE-ID",
// [!code ++]
},
// [!code ++]
],
}

Typer le binding pour TypeScript

Dans Astro 6 (adapter @astrojs/cloudflare 13.x), générez les types à partir des bindings déclarés dans wrangler.jsonc :

Terminal window
npx wrangler types

Cela crée un worker-configuration.d.ts avec l’interface globale Env (qui inclut déjà votre DB) et les définitions du module cloudflare:workers. Le fichier est récupéré par votre tsconfig.json via le motif include habituel.

L’endpoint du compteur

Créez src/pages/api/views/[slug].ts. Un seul POST incrémente et renvoie le total :

src/pages/api/views/[slug].ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";
// Le site peut être statique ; cet endpoint doit être dynamique.
export const prerender = false;
export const POST: APIRoute = async ({ params }) => {
const slug = params.slug;
if (!slug) {
return new Response("Slug manquant", { status: 400 });
}
const db = env.DB;
// UPSERT atomique : insère avec 1, ou ajoute 1 si la ligne existe déjà.
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 });
};
// Optionnel : lire sans incrémenter (p. ex. pour une page de statistiques).
export const GET: APIRoute = async ({ params }) => {
const slug = params.slug;
if (!slug) return new Response("Slug manquant", { 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 });
};

Le composant visuel

Créez un composant réutilisable src/components/ViewCounter.astro qui reçoit le slug et affiche le nombre :

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> vues
</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 s'exécute au chargement initial et après chaque navigation ClientRouter.
document.addEventListener("astro:page-load", loadViewCount);
</script>

Utilisez-le dans le layout de l’article (dans AstroPaper, typiquement src/layouts/PostDetails.astro ou équivalent), en lui passant le slug du billet :

---
import ViewCounter from "@/components/ViewCounter.astro";
// `post` est l'entrée de l'article dans votre layout.
---
<ViewCounter slug={post.id} />

Partie 2 : SvelteKit avec l’adapter Cloudflare en SSR

Le modèle est le même ; la mécanique du framework change. SvelteKit expose les bindings Cloudflare via event.platform.env.

Adapter et binding

Assurez-vous d’utiliser @sveltejs/adapter-cloudflare dans 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(),
},
};

Déclarez le binding DB comme précédemment, dans wrangler.jsonc :

wrangler.jsonc
{
"name": "mon-blog-sveltekit",
"compatibility_date": "2026-03-06",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "views",
"database_id": "COLLEZ-ICI-LE-DATABASE-ID",
},
],
}

Typer la plateforme

Dans SvelteKit, les bindings vivent dans App.Platform. Déclarez-les dans src/app.d.ts :

src/app.d.ts
declare global {
namespace App {
interface Platform {
env: {
DB: D1Database;
};
}
}
}
export {};

L’endpoint du compteur

Créez 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, "Binding D1 non 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, "Binding D1 non disponible");
const row = await db
.prepare("SELECT count FROM views WHERE slug = ?")
.bind(params.slug)
.first<{ count: number }>();
return json({ count: row?.count ?? 0 });
};

Le composant visuel

Créez 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)} vues
</span>

Utilisez-le dans la page de l’article (src/routes/blog/[slug]/+page.svelte ou là où vous rendez le billet) :

<script lang="ts">
import ViewCounter from "$lib/components/ViewCounter.svelte";
export let data; // vient de votre +page.ts / load
</script>
<ViewCounter slug={data.post.slug} />

Personnalisation graphique optionnelle

Le nombre honnête suffit déjà, mais une partie du charme des compteurs à l’ancienne venait de leur apparence : chiffres blancs sur fond noir, style affichage LED ou compteur mécanique. Il existe deux chemins pour l’obtenir.

Option A (recommandée) : style LED avec CSS

Donnez au compteur une apparence d’afficheur numérique avec une police monospace, un fond sombre et une légère lueur. Le composant ne change pas : ajoutez seulement des styles. Sur le <span class="view-counter"> du composant Astro (ou le <span> du composant Svelte), ajoutez :

styles du compteur (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; /* vert phosphore classique */
text-shadow: 0 0 6px #00ff6a; /* lueur LED */
}

Pour le classique blanc sur noir de l’annexe, remplacez par color: #fff et supprimez ou ajustez le text-shadow.

Option A+ : chaque chiffre dans sa propre « case »

Le rendu de compteur mécanique (chaque chiffre dans son propre rectangle noir) nécessite d’envelopper chaque chiffre dans son propre élément. Modifiez le rendu pour construire un <span> par chiffre au lieu d’écrire le nombre brut. Dans le composant Astro :

script du composant, rendu par chiffres
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);
}
});

Avec son CSS :

cases par chiffre
.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) {
/* séparateur des milliers */
padding: 0 0.1em;
color: #888;
}

Option B : images pour les chiffres (la méthode rétro)

C’est la méthode originale des années 1990 : une image par chiffre (0.gif9.gif), ou un seul sprite avec les dix chiffres, et le nombre est composé en plaçant l’image correcte pour chaque chiffre. Elle reproduit un pixel-art exact que CSS ne peut pas toujours imiter, mais avec des contreparties :

Si vous le voulez quand même pour l’esthétique, le rendu place une balise <img> par chiffre :

script du composant, chiffres comme images
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json() as Promise<{ count: number }>)
.then(d => {
const n = String(d.count); // sans séparateurs, plus simple
el.textContent = "";
el.setAttribute("aria-label", `${n} vues`);
for (const ch of n) {
const img = document.createElement("img");
img.src = `/counter/${ch}.gif`; // 0.gif … 9.gif dans /public/counter/
img.alt = ""; // décoratif ; aria-label porte la valeur
img.className = "digit-img";
el.appendChild(img);
}
});
images de chiffres
.view-counter :global(.digit-img) {
height: 1.2em;
width: auto;
image-rendering: pixelated; /* conserve les bords pixel-art lors du redimensionnement */
vertical-align: middle;
}

Placez vos fichiers 0.gif9.gif dans public/counter/ (Astro) ou static/counter/ (SvelteKit) afin qu’ils soient servis comme assets statiques.


Variante optionnelle : ne pas compter les rechargements du même visiteur

Le design de base compte chaque impression, rechargements inclus. Pour vous rapprocher des « vues » sans suivre l’utilisateur, marquez dans localStorage que ce navigateur a déjà compté cet article. C’est une déduplication purement locale : pas de cookies, pas d’identifiants, pas de données envoyées à des tiers.

Remplacez l’appel fetch par :

const key = `viewed:${slug}`;
if (!localStorage.getItem(key)) {
localStorage.setItem(key, "1");
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json())
.then(d => {
/* afficher d.count */
});
} else {
// Déjà compté auparavant : lire seulement le total sans incrémenter.
fetch(`/api/views/${slug}`)
.then(r => r.json())
.then(d => {
/* afficher d.count */
});
}

Durcissement de base

Quelques mesures optionnelles, par ordre d’utilité réelle pour un blog :


Troubleshooting

Le nombre apparaît comme « — » ou ne change pas. Ouvrez la console du navigateur et l’onglet réseau. Vérifiez que POST /api/views/<slug> répond 200 avec { "count": N }. Un 500 signifie généralement que le binding DB n’est pas arrivé jusqu’à l’endpoint.

env.DB is undefined (Astro) ou platform is undefined (SvelteKit). Le binding n’est pas connecté. Vérifiez que d1_databases se trouve dans wrangler.jsonc avec le bon database_id, que le nom du binding correspond à celui utilisé dans le code (DB), que vous avez redéployé et —en développement— que vous exécutez avec l’adapter Cloudflare (pas un dev sans émulation de plateforme).

La propriété 'runtime' n'existe pas sur le type 'Locals' (Astro). Vous utilisez l’ancien motif Astro.locals.runtime.env, supprimé dans Astro 6. Importez env depuis cloudflare:workers (voir l’endpoint) et générez les types avec npx wrangler types.

no such table: views. Vous n’avez pas appliqué le schéma dans l’environnement que vous interrogez. Rappelez-vous que --local et --remote sont des bases différentes : exécutez le schema.sql dans les deux selon le cas.

Cela fonctionne en local mais pas en production (ou l’inverse). C’est presque toujours le même problème --local vs --remote. La base locale de wrangler dev n’est pas la base utilisée par votre Worker déployé.

Le compteur augmente de deux en deux. En développement, le Strict Mode de React (si vous l’utilisez dans des îlots Astro) ou un double montage peuvent déclencher deux fetch. En production, cela ne se produit pas ; si cela vous gêne en dev, utilisez la variante avec localStorage.


Conclusion

Recréer le compteur de visites à l’ancienne sur Cloudflare est étonnamment direct : une table D1 avec une ligne par article, un endpoint qui fait un UPSERT atomique et un script client d’une dizaine de lignes. Il ne faut pas de serveur séparé, il ne faut pas activer le SSR uniquement pour cela, et le coût est nul en pratique.

Le même modèle fonctionne pour Astro et SvelteKit ; la seule chose qui change est la manière dont chaque framework vous fournit le binding D1 : import { env } from "cloudflare:workers" dans Astro 6, platform.env dans SvelteKit. La logique de base de données —l’instruction SQL, l’incrément atomique, la lecture du total— est exactement la même dans les deux.

Et, fidèle à l’esprit original, le compteur compte les impressions honnêtement sans suivre personne : un simple nombre au pied de l’article, comme autrefois.

Annexe : permissions du token API Cloudflare

Les opérations de ce guide —créer la base de données D1 et appliquer le schéma avec Wrangler— ne touchent que D1. Elles n’ont pas besoin de permissions Workers, DNS ni du reste du compte.

Si vous utilisez wrangler login (flux OAuth interactif), Wrangler obtient les permissions pour vous et vous n’avez pas besoin de créer un token. Cette annexe s’applique lorsque vous préférez un token API explicite, par exemple pour exécuter Wrangler dans un environnement non interactif ou avec des identifiants limités.

Permission minimale nécessaire

L’opération est similaire à celle décrite dans cet article sur le déploiement de SvelteKit sur Cloudflare.

Pour D1, la permission minimale est :

Opération du guideCommandePermission requise
Créer la base de donnéeswrangler d1 create viewsAccount → D1 → Edit
Appliquer le schéma (distant)wrangler d1 execute views --remote --file=./schema.sqlAccount → D1 → Edit
Précharger/consulter des lignes (distant)wrangler d1 execute ... --command "..."Account → D1 → Edit

Cela suffit. Edit inclut la lecture et l’écriture sur D1 ; vous n’avez pas besoin d’une permission de lecture séparée. Les opérations --local ne touchent pas l’API Cloudflare (elles s’exécutent contre une copie locale sur disque), donc elles ne consomment aucune permission du token.

Capture d'écran de la section des permissions d'un token Cloudflare, montrant « Account → D1 → Edit »

Créer le token

  1. Allez dans My Profile → API Tokens et choisissez Create Token → Create Custom Token.
  2. Dans Permissions, ajoutez une ligne : Account · D1 · Edit.
  3. Dans Account Resources, sélectionnez Include → votre compte (pas « All accounts »).
  4. Optionnel mais recommandé : définissez un TTL (date d’expiration) et, si votre réseau est fixe, limitez par Client IP Address Filtering.
  5. Créez le token et copiez-le une seule fois ; Cloudflare ne l’affichera plus.

Utiliser le token avec Wrangler

Wrangler lit le token depuis la variable d’environnement CLOUDFLARE_API_TOKEN. Pour les commandes D1, il est aussi utile de fixer CLOUDFLARE_ACCOUNT_ID (visible dans le tableau de bord de votre compte) :

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

Références



Previous Post
Comment configurer un système audio 4.1 sous Linux avec PipeWire, JamesDSP et systemd
Next Post
Google Authenticator sur deux appareils ou plus : guide technique pour ne pas perdre l'accès.