Skip to content
rodolfo.gg
Go back

So fügst du einem Cloudflare-Blog mit D1 einen Old-School-Besucherzähler hinzu, für Astro und SvelteKit in SSR.

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

So fügst du einem Cloudflare-Blog mit D1 einen Old-School-Besucherzähler hinzu, für Astro und SvelteKit in SSR.

Einführung

Es gab eine Zeit, vor Google Analytics und vor Produkt-Dashboards, in der fast jede Website stolz einen kleinen Zähler am Seitenende zeigte: „Du bist Besucher Nummer 12.345“. Diese Old-School-Zähler verfolgten keine Personen, erstellten keine Zielgruppenprofile und sendeten keine Daten an Dritte. Sie zählten nur Impressionen und zeigten eine ehrliche Zahl.

Diese Anleitung beschreibt, wie du diese Idee für einen modernen Blog auf Cloudflare nachbauen kannst: einen Impressions- oder Aufrufzähler pro Artikel, persistent in einer Datenbank gespeichert, ohne separaten Server, praktisch ohne Kosten und ohne die Privatsphäre der Leser zu opfern.

Als Speicher dient Cloudflare D1, eine verwaltete SQLite-Datenbank, die am Edge läuft und über ein Binding mit deinem Worker verbunden wird. D1 wurde statt KV gewählt, weil KV keinen atomaren Inkrementbefehl hat und bei gleichzeitigem Traffic Zählungen verlieren würde. D1 führt count = count + 1 atomar in einer einzigen SQL-Anweisung aus.

Die Anleitung behandelt zwei Implementierungen desselben Musters:

In beiden Fällen hat das Ergebnis diese Eigenschaften:


Inhaltsverzeichnis

Inhaltsverzeichnis

Allgemeine Architektur

Das Muster ist in beiden Frameworks identisch. Es ändert sich nur, wie das Framework das D1-Binding dem Endpoint-Code bereitstellt.

flowchart LR
    READER["Browser des Lesers"] -->|"POST /api/views/:slug"| WORKER["Worker am Edge (Astro oder SvelteKit mit Cloudflare-Adapter)"]
    WORKER -->|"DB-Binding"| D1[("Cloudflare D1 SQLite")]
    D1 -->|"aktualisierter count"| WORKER
    WORKER -->|"JSON { count }"| READER
    READER -->|"rendern"| DOM["12.345 Aufrufe"]

Der Ablauf bei jedem Laden eines Artikels:

  1. Die Seite wird normal gerendert (statisch oder SSR, das spielt keine Rolle).
  2. Ein kleines clientseitiges Script sendet beim Laden POST /api/views/<slug>.
  3. Der Endpoint, innerhalb desselben Workers, führt ein atomares UPSERT in D1 aus und gibt die neue Summe zurück.
  4. Das Script schreibt die Zahl in den DOM.

Voraussetzungen

Verwende diese Checkliste vor der Implementierung:

  • Du hast einen Blog auf Cloudflare Workers (oder Pages) mit Astro + @astrojs/cloudflare oder mit SvelteKit + @sveltejs/adapter-cloudflare bereitgestellt. - [ ] Du hast Wrangler installiert und authentifiziert (npx wrangler login). - [ ] Dein Projekt deployt bereits korrekt, bevor du den Zähler hinzufügst. - [ ] Du kennst den slug oder eindeutigen Bezeichner jedes Artikels, der zur Renderzeit verfügbar ist (Frontmatter, Routenparameter usw.). - [ ] Du akzeptierst, dass der Anfangszähler bei null beginnt; es gibt keine historischen Daten zu importieren (falls doch, kannst du sie mit einem INSERT vorladen). - [ ] Du bist damit einverstanden, Impressionen zu zählen, keine eindeutigen Nutzer, es sei denn, du verwendest die unten beschriebene optionale Deduplizierung.

Kostet das etwas?

Für einen persönlichen Blog: nein. Die Limits des kostenlosen Cloudflare-Plans liegen weit über dem, was ein Besucherzähler verbraucht:

RessourceLimit des kostenlosen PlansVerbrauch des Zählers
Workers / Aufrufe100.000 Requests/Tag1 POST pro Artikelladung
D1-Zeilenlesungen5.000.000 gelesene Zeilen/Tag1 Zeile pro Impression
D1-Zeilenschreibungen100.000 geschriebene Zeilen/Tag1 Zeile pro Impression
D1-Speicher5 GBeine winzige Zeile pro Artikel

Eine einzige Zeile pro Artikel (Slug + Integer) wiegt nur wenige Bytes. Selbst ein Blog mit Zehntausenden täglichen Besuchen bleibt komfortabel im kostenlosen Plan. Wenn du ihn irgendwann überschreitest, berechnet der D1-Bezahlplan gelesene/geschriebene Zeilen und GB-Monate; für diesen Anwendungsfall sind das Cent-Beträge.


D1-Datenbank erstellen

Dieser Schritt ist für Astro und SvelteKit identisch. Die Tabelle ist dieselbe.

Erstelle die Datenbank:

Terminal window
npx wrangler d1 create views

Wrangler gibt einen Block mit der database_id aus. Bewahre sie auf; du brauchst sie in wrangler.jsonc.

Erstelle die Tabelle. Es ist sinnvoll, sie in einer versionierten Schema-Datei zu speichern:

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

Wende sie zuerst remote an (die echte Datenbank, die dein deployed Worker nutzt):

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

Und, falls du lokal mit wrangler dev entwickelst, auch auf die lokale Kopie:

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

Teil 1: Astro (AstroPaper) in SSR mit dem Cloudflare-Adapter

Dieser Teil setzt einen Blog im Stil von AstroPaper mit output: 'server' und adapter: cloudflare() voraus.

D1-Binding in wrangler deklarieren

Füge das Binding DB zu deinem wrangler.jsonc hinzu (neben assets, compatibility_date usw.):

wrangler.jsonc
{
"name": "mein-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": "HIER-DIE-DATABASE-ID-EINFUEGEN",
// [!code ++]
},
// [!code ++]
],
}

Binding für TypeScript typisieren

In Astro 6 (Adapter @astrojs/cloudflare 13.x) generierst du die Typen aus den in wrangler.jsonc deklarierten Bindings:

Terminal window
npx wrangler types

Das erzeugt eine Datei worker-configuration.d.ts mit dem globalen Interface Env (das dein DB bereits enthält) sowie den Definitionen des Moduls cloudflare:workers. Deine tsconfig.json nimmt die Datei über das übliche include-Muster auf.

Der Zähler-Endpoint

Erstelle src/pages/api/views/[slug].ts. Ein einziger POST inkrementiert und gibt die Summe zurück:

src/pages/api/views/[slug].ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";
// Die Website kann statisch sein; dieser Endpoint muss dynamisch sein.
export const prerender = false;
export const POST: APIRoute = async ({ params }) => {
const slug = params.slug;
if (!slug) {
return new Response("Slug fehlt", { status: 400 });
}
const db = env.DB;
// Atomarer UPSERT: mit 1 einfügen oder 1 addieren, wenn die Zeile schon existiert.
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 });
};
// Optional: lesen ohne zu inkrementieren (z. B. für eine Statistikseite).
export const GET: APIRoute = async ({ params }) => {
const slug = params.slug;
if (!slug) return new Response("Slug fehlt", { 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 });
};

Die visuelle Komponente

Erstelle eine wiederverwendbare Komponente src/components/ViewCounter.astro, die den Slug entgegennimmt und die Zahl rendert:

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> Aufrufe
</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 läuft beim ersten Laden und nach jeder ClientRouter-Navigation.
document.addEventListener("astro:page-load", loadViewCount);
</script>

Verwende sie im Artikel-Layout (in AstroPaper typischerweise src/layouts/PostDetails.astro oder ähnlich) und übergib den Slug des Posts:

---
import ViewCounter from "@/components/ViewCounter.astro";
// `post` ist der Artikel-Eintrag in deinem Layout.
---
<ViewCounter slug={post.id} />

Teil 2: SvelteKit mit dem Cloudflare-Adapter in SSR

Das Muster ist dasselbe; die Framework-Mechanik ändert sich. SvelteKit stellt Cloudflare-Bindings über event.platform.env bereit.

Adapter und Binding

Stelle sicher, dass du @sveltejs/adapter-cloudflare in svelte.config.js verwendest:

svelte.config.js
import adapter from "@sveltejs/adapter-cloudflare";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
export default {
preprocess: vitePreprocess(),
kit: {
adapter: adapter(),
},
};

Deklariere das Binding DB wie zuvor in wrangler.jsonc:

wrangler.jsonc
{
"name": "mein-sveltekit-blog",
"compatibility_date": "2026-03-06",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "views",
"database_id": "HIER-DIE-DATABASE-ID-EINFUEGEN",
},
],
}

Plattform typisieren

In SvelteKit leben Bindings in App.Platform. Deklariere sie in src/app.d.ts:

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

Der Zähler-Endpoint

Erstelle 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 nicht verfügbar");
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 nicht verfügbar");
const row = await db
.prepare("SELECT count FROM views WHERE slug = ?")
.bind(params.slug)
.first<{ count: number }>();
return json({ count: row?.count ?? 0 });
};

Die visuelle Komponente

Erstelle 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)} Aufrufe
</span>

Verwende sie auf der Artikelseite (src/routes/blog/[slug]/+page.svelte oder dort, wo du den Post renderst):

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

Optionale visuelle Anpassung

Die ehrliche Zahl reicht bereits aus, aber ein Teil des Charmes alter Besucherzähler war ihr Aussehen: weiße Ziffern auf schwarzem Grund, LED-Display-Stil oder mechanische Zählwerk-Optik. Es gibt zwei Wege dahin.

Option A (empfohlen): LED-Stil mit CSS

Gib dem Zähler mit einer Monospace-Schrift, dunklem Hintergrund und leichtem Leuchten ein digitales Display-Aussehen. Die Komponente ändert sich nicht: Du fügst nur Styles hinzu. Für das <span class="view-counter"> der Astro-Komponente (oder das <span> der Svelte-Komponente) ergänzt du:

Zähler-Styles (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; /* klassisches Phosphorgrün */
text-shadow: 0 0 6px #00ff6a; /* LED-Leuchten */
}

Für das klassische Weiß auf Schwarz aus dem Anhang änderst du color: #fff und entfernst oder passt text-shadow an.

Option A+: jede Ziffer in einem eigenen „Feld“

Die mechanische Zählwerk-Optik (jede Ziffer in einem eigenen schwarzen Feld) erfordert, jede Ziffer in ein eigenes Element einzuwickeln. Ändere das Rendering so, dass pro Ziffer ein <span> erzeugt wird, statt die rohe Zahl zu schreiben. In der Astro-Komponente:

Komponenten-Script, Rendering pro Ziffer
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);
}
});

Mit dem passenden CSS:

Felder pro Ziffer
.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) {
/* Tausendertrennzeichen */
padding: 0 0.1em;
color: #888;
}

Option B: Bilder für die Ziffern (die Retro-Methode)

Das ist die ursprüngliche Methode der Neunziger: ein Bild pro Ziffer (0.gif9.gif) oder ein einzelnes Sprite mit allen zehn Ziffern, und die Zahl wird zusammengesetzt, indem für jede Ziffer das passende Bild platziert wird. Das reproduziert exakte Pixel-Art, die CSS nicht immer nachahmen kann, hat aber Nachteile:

Wenn du es aus ästhetischen Gründen trotzdem möchtest, platziert das Rendering ein <img> pro Ziffer:

Komponenten-Script, Ziffern als Bilder
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json() as Promise<{ count: number }>)
.then(d => {
const n = String(d.count); // ohne Trennzeichen, einfacher
el.textContent = "";
el.setAttribute("aria-label", `${n} Aufrufe`);
for (const ch of n) {
const img = document.createElement("img");
img.src = `/counter/${ch}.gif`; // 0.gif … 9.gif in /public/counter/
img.alt = ""; // dekorativ; aria-label trägt den Wert
img.className = "digit-img";
el.appendChild(img);
}
});
Ziffernbilder
.view-counter :global(.digit-img) {
height: 1.2em;
width: auto;
image-rendering: pixelated; /* erhält scharfe Pixel-Art-Kanten beim Skalieren */
vertical-align: middle;
}

Lege deine Dateien 0.gif9.gif in public/counter/ (Astro) oder static/counter/ (SvelteKit), damit sie als statische Assets ausgeliefert werden.


Optionale Variante: Reloads desselben Besuchers nicht zählen

Das Basisdesign zählt jede Impression, einschließlich Reloads. Um näher an „Aufrufe“ heranzukommen, ohne den Nutzer zu tracken, markierst du in localStorage, dass dieser Browser diesen Artikel bereits gezählt hat. Das ist rein lokale Deduplizierung: keine Cookies, keine IDs, keine Daten an Dritte.

Ersetze den fetch-Aufruf durch:

const key = `viewed:${slug}`;
if (!localStorage.getItem(key)) {
localStorage.setItem(key, "1");
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json())
.then(d => {
/* d.count rendern */
});
} else {
// Wurde schon gezählt: nur die Summe lesen, ohne zu inkrementieren.
fetch(`/api/views/${slug}`)
.then(r => r.json())
.then(d => {
/* d.count rendern */
});
}

Grundlegende Härtung

Einige optionale Maßnahmen, geordnet nach tatsächlichem Nutzen für einen Blog:


Troubleshooting

Die Zahl erscheint als „—“ oder ändert sich nicht. Öffne die Browser-Konsole und den Netzwerk-Tab. Prüfe, ob POST /api/views/<slug> mit 200 und { "count": N } antwortet. Ein 500 bedeutet meistens, dass das Binding DB den Endpoint nicht erreicht hat.

env.DB is undefined (Astro) oder platform is undefined (SvelteKit). Das Binding ist nicht verbunden. Prüfe, ob d1_databases mit der richtigen database_id in wrangler.jsonc steht, ob der Name des binding mit dem im Code verwendeten Namen (DB) übereinstimmt, ob du neu deployed hast und —in der Entwicklung— ob du mit dem Cloudflare-Adapter läufst (nicht mit einem dev ohne Plattform-Emulation).

Property 'runtime' does not exist on type 'Locals' (Astro). Du verwendest das alte Muster Astro.locals.runtime.env, das in Astro 6 entfernt wurde. Importiere env aus cloudflare:workers (siehe Endpoint) und generiere die Typen mit npx wrangler types.

no such table: views. Du hast das Schema nicht in der Umgebung angewendet, die du gerade triffst. Denke daran, dass --local und --remote verschiedene Datenbanken sind: Führe schema.sql je nach Bedarf in beiden aus.

Es funktioniert lokal, aber nicht in Produktion (oder umgekehrt). Fast immer ist es dasselbe --local-vs.---remote-Problem. Die lokale Datenbank von wrangler dev ist nicht die Datenbank, die dein deployed Worker verwendet.

Der Zähler steigt in Zweierschritten. In der Entwicklung können React Strict Mode (falls du ihn in Astro-Islands nutzt) oder ein doppeltes Mounting zwei fetch-Aufrufe auslösen. In Produktion passiert das nicht; wenn es dich in dev stört, nutze die Variante mit localStorage.


Fazit

Den Old-School-Besucherzähler auf Cloudflare nachzubauen ist überraschend direkt: eine D1-Tabelle mit einer Zeile pro Artikel, ein Endpoint mit atomarem UPSERT und ein clientseitiges Script von etwa zehn Zeilen. Es braucht keinen separaten Server, SSR muss nicht nur dafür aktiviert werden, und die Kosten sind in der Praxis null.

Dasselbe Muster funktioniert für Astro und SvelteKit; nur die Art ändert sich, wie das Framework dir das D1-Binding bereitstellt: import { env } from "cloudflare:workers" in Astro 6, platform.env in SvelteKit. Die Datenbanklogik —SQL-Anweisung, atomares Inkrement, Lesen der Summe— ist in beiden Fällen exakt gleich.

Und dem ursprünglichen Geist treu zählt der Zähler Impressionen ehrlich und ohne irgendwen zu tracken: eine einfache Zahl am Ende des Artikels, wie früher.

Anhang: Berechtigungen des Cloudflare-API-Tokens

Die Operationen in dieser Anleitung —D1-Datenbank erstellen und das Schema mit Wrangler anwenden— berühren nur D1. Sie benötigen keine Berechtigungen für Workers, DNS oder andere Teile des Kontos.

Wenn du wrangler login verwendest (interaktiver OAuth-Flow), holt Wrangler die Berechtigungen für dich ein, und du musst kein Token erstellen. Dieser Anhang gilt, wenn du lieber ein explizites API-Token verwendest, zum Beispiel um Wrangler in einer nicht interaktiven Umgebung oder mit eingeschränkten Zugangsdaten auszuführen.

Minimal notwendige Berechtigung

Die Operation ähnelt der in diesem Beitrag über das Deployment von SvelteKit auf Cloudflare beschriebenen.

Für D1 ist die minimale Berechtigung:

Operation der AnleitungBefehlErforderliche Berechtigung
Datenbank erstellenwrangler d1 create viewsAccount → D1 → Edit
Schema anwenden (remote)wrangler d1 execute views --remote --file=./schema.sqlAccount → D1 → Edit
Zeilen vorladen/abfragen (remote)wrangler d1 execute ... --command "..."Account → D1 → Edit

Das reicht. Edit umfasst Lesen und Schreiben in D1; du brauchst keine separate Leseberechtigung. --local-Operationen berühren die Cloudflare-API nicht (sie laufen gegen eine lokale Kopie auf der Festplatte) und verbrauchen daher keine Token-Berechtigungen.

Screenshot des Berechtigungsbereichs eines Cloudflare-Tokens mit „Account → D1 → Edit“

Token erstellen

  1. Gehe zu My Profile → API Tokens und wähle Create Token → Create Custom Token.
  2. Füge unter Permissions eine Zeile hinzu: Account · D1 · Edit.
  3. Wähle unter Account Resources Include → dein Konto (nicht „All accounts“).
  4. Optional, aber empfohlen: Setze eine TTL (Ablaufdatum) und beschränke das Token, falls dein Netzwerk fest ist, über Client IP Address Filtering.
  5. Erstelle das Token und kopiere es genau einmal; Cloudflare zeigt es nicht erneut an.

Token mit Wrangler verwenden

Wrangler liest das Token aus der Umgebungsvariable CLOUDFLARE_API_TOKEN. Für D1-Befehle ist es außerdem sinnvoll, CLOUDFLARE_ACCOUNT_ID zu setzen (sichtbar im Dashboard deines Kontos):

Terminal window
export CLOUDFLARE_API_TOKEN="dein-auf-D1-Edit-beschraenktes-token"
export CLOUDFLARE_ACCOUNT_ID="deine-account-id"
npx wrangler d1 create views
npx wrangler d1 execute views --remote --file=./schema.sql

Referenzen



Previous Post
So konfigurierst du ein 4.1-Audiosystem unter Linux mit PipeWire, JamesDSP und systemd
Next Post
Google Authenticator auf zwei oder mehr Geräten: technischer Leitfaden, um den Zugang nicht zu verlieren.