Skip to content
rodolfo.gg
Go back

How to add an old-school visit counter to a Cloudflare blog with D1, for Astro and SvelteKit in SSR.

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

How to add an old-school visit counter to a Cloudflare blog with D1, for Astro and SvelteKit in SSR.

Introduction

There was a time, before Google Analytics and before product dashboards, when almost every website proudly displayed a small counter at the bottom of the page: “You are visitor number 12,345”. Those old-school counters did not track people, did not profile audiences, and did not send data to third parties. They only counted impressions and displayed an honest number.

This guide explains how to recreate that idea for a modern blog deployed on Cloudflare: a per-article impression or view counter, persisted in a database, without a separate server, effectively at no cost and without sacrificing reader privacy.

Storage will be Cloudflare D1, a managed SQLite database that runs at the edge and connects to your Worker through a binding. D1 was chosen instead of KV because KV has no atomic increment, so under concurrent traffic it would lose counts. D1 performs count = count + 1 atomically in a single SQL statement.

The guide covers two implementations of the same pattern:

In both cases, the result has these properties:


Table of contents

Table of contents

General architecture

The pattern is identical in both frameworks. Only the way the framework exposes the D1 binding to the endpoint code changes.

flowchart LR
    READER["Reader browser"] -->|"POST /api/views/:slug"| WORKER["Worker at the edge (Astro or SvelteKit with Cloudflare adapter)"]
    WORKER -->|"DB binding"| D1[("Cloudflare D1 SQLite")]
    D1 -->|"updated count"| WORKER
    WORKER -->|"JSON { count }"| READER
    READER -->|"render"| DOM["12,345 views"]

The flow on each article load:

  1. The page renders normally (static or SSR, it does not matter).
  2. A small client-side script sends POST /api/views/<slug> on load.
  3. The endpoint, inside the same Worker, runs an atomic UPSERT in D1 and returns the new total.
  4. The script writes the number into the DOM.

Prerequisites

Use this checklist before implementing:

  • You have a blog deployed on Cloudflare Workers (or Pages) with Astro
  • @astrojs/cloudflare, or with SvelteKit + @sveltejs/adapter-cloudflare. - [ ] You have Wrangler installed and authenticated (npx wrangler login).
  • Your project already deploys correctly before adding the counter. - [ ] You know the slug or unique identifier of each article, available at render time (frontmatter, route parameter, etc.). - [ ] You accept that the initial count starts at zero; there is no historical data to import (if you have any, you can preload it with an INSERT). - [ ] You agree to count impressions, not unique users, unless you apply the optional deduplication described below.

Does it cost anything?

For a personal blog, no. Cloudflare’s free-plan limits are far above what a visit counter consumes:

ResourceFree-plan limitWhat the counter consumes
Workers / invocations100,000 requests/day1 POST per article load
D1 row reads5,000,000 rows read/day1 row per impression
D1 row writes100,000 rows written/day1 row per impression
D1 storage5 GBone tiny row per article

A single row per article (slug + integer) weighs bytes. Even a blog with tens of thousands of daily visits remains comfortably within the free plan. If you ever exceed it, D1’s paid plan charges for rows read/written and GB-months, in cent-level amounts for this use case.


Create the D1 database

This step is identical for Astro and SvelteKit. The table is the same.

Create the database:

Terminal window
npx wrangler d1 create views

Wrangler prints a block containing the database_id. Save it; you will need it in wrangler.jsonc.

Create the table. It is best to keep it in a versioned schema file:

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

Apply it first remotely (the real database used by your deployed Worker):

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

And, if you develop locally with wrangler dev, also apply it to the local copy:

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

Part 1: Astro (AstroPaper) in SSR with the Cloudflare adapter

This part assumes an AstroPaper-style blog with output: 'server' and adapter: cloudflare().

Declare the D1 binding in wrangler

Add the DB binding to your wrangler.jsonc (alongside assets, compatibility_date, etc.):

wrangler.jsonc
{
"name": "my-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": "PASTE-THE-DATABASE-ID-HERE",
// [!code ++]
},
// [!code ++]
],
}

Type the binding for TypeScript

In Astro 6 (@astrojs/cloudflare adapter 13.x), generate the types from the bindings declared in wrangler.jsonc:

Terminal window
npx wrangler types

This creates a worker-configuration.d.ts file with the global Env interface (which already includes your DB) and the definitions for the cloudflare:workers module. Your tsconfig.json picks up the file through the usual include pattern.

The counter endpoint

Create src/pages/api/views/[slug].ts. A single POST increments and returns the total:

src/pages/api/views/[slug].ts
import type { APIRoute } from "astro";
import { env } from "cloudflare:workers";
// The site can be static; this endpoint must be dynamic.
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;
// Atomic UPSERT: insert with 1, or add 1 if it already exists.
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: read without incrementing (e.g. for a stats page).
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 });
};

The visual component

Create a reusable src/components/ViewCounter.astro component that receives the slug and renders the number:

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> views
</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 runs on the initial load and after every ClientRouter navigation.
document.addEventListener("astro:page-load", loadViewCount);
</script>

Use it in the article layout (in AstroPaper, typically src/layouts/PostDetails.astro or equivalent), passing the post slug:

---
import ViewCounter from "@/components/ViewCounter.astro";
// `post` is the article entry in your layout.
---
<ViewCounter slug={post.id} />

Part 2: SvelteKit with the Cloudflare adapter in SSR

The pattern is the same; the framework mechanics change. SvelteKit exposes Cloudflare bindings through event.platform.env.

Adapter and binding

Make sure you use @sveltejs/adapter-cloudflare in 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(),
},
};

Declare the DB binding as before, in wrangler.jsonc:

wrangler.jsonc
{
"name": "my-sveltekit-blog",
"compatibility_date": "2026-03-06",
"compatibility_flags": ["nodejs_compat"],
"d1_databases": [
{
"binding": "DB",
"database_name": "views",
"database_id": "PASTE-THE-DATABASE-ID-HERE",
},
],
}

Type the platform

In SvelteKit, bindings live in App.Platform. Declare them in src/app.d.ts:

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

The counter endpoint

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

The visual component

Create 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)} views
</span>

Use it in the article page (src/routes/blog/[slug]/+page.svelte or wherever you render the post):

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

Optional visual customization

The honest number is already enough, but part of the charm of old-school counters was their look: white digits on a black background, LED display style, or a mechanical tally-board style. There are two ways to get there.

Give the counter a digital-display look with a monospace font, dark background, and a subtle glow. The component does not change: only add styles. On the <span class="view-counter"> from the Astro component (or the <span> from the Svelte component), add:

counter 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; /* classic phosphor green */
text-shadow: 0 0 6px #00ff6a; /* LED glow */
}

For the classic white on black look from the appendix, change color: #fff and remove or adjust the text-shadow.

Option A+: each digit in its own “slot”

The mechanical counter look (each digit in its own black box) requires wrapping each digit in its own element. Modify rendering to build a <span> per digit instead of writing the plain number. In the Astro component:

component script, digit rendering
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);
}
});

With its CSS:

digit slots
.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) {
/* thousands comma */
padding: 0 0.1em;
color: #888;
}

Option B: images for the digits (the retro method)

This is the original nineties method: one image per digit (0.gif9.gif), or a single sprite with all ten digits, with the number composed by placing the correct image for each digit. It reproduces exact pixel art that CSS cannot always imitate, but at a cost:

If you still want it for aesthetic reasons, rendering places one <img> per digit:

component script, digits as images
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json() as Promise<{ count: number }>)
.then(d => {
const n = String(d.count); // no separators, simpler
el.textContent = "";
el.setAttribute("aria-label", `${n} views`);
for (const ch of n) {
const img = document.createElement("img");
img.src = `/counter/${ch}.gif`; // 0.gif … 9.gif in /public/counter/
img.alt = ""; // decorative; aria-label carries the value
img.className = "digit-img";
el.appendChild(img);
}
});
digit images
.view-counter :global(.digit-img) {
height: 1.2em;
width: auto;
image-rendering: pixelated; /* keeps pixel-art edges crisp when scaled */
vertical-align: middle;
}

Place your 0.gif9.gif files in public/counter/ (Astro) or static/counter/ (SvelteKit) so they are served as static assets.


Optional variant: do not count reloads from the same visitor

The base design counts every impression, including reloads. To get closer to “views” without tracking the user, mark in localStorage that this browser has already counted this article. It is purely local deduplication: no cookies, no IDs, and no data sent to third parties.

Replace the fetch call with:

const key = `viewed:${slug}`;
if (!localStorage.getItem(key)) {
localStorage.setItem(key, "1");
fetch(`/api/views/${slug}`, { method: "POST" })
.then(r => r.json())
.then(d => {
/* render d.count */
});
} else {
// Already counted before: only read the total without incrementing.
fetch(`/api/views/${slug}`)
.then(r => r.json())
.then(d => {
/* render d.count */
});
}

Basic hardening

Some optional measures, ordered by real usefulness for a blog:


Troubleshooting

The number appears as ”—” or does not change. Open the browser console and the network tab. Verify that POST /api/views/<slug> responds 200 with { "count": N }. A 500 usually means the DB binding did not reach the endpoint.

env.DB is undefined (Astro) or platform is undefined (SvelteKit). The binding is not connected. Check that d1_databases is in wrangler.jsonc with the correct database_id, that the binding name matches what your code uses (DB), that you redeployed, and —in development— that you are running with the Cloudflare adapter (not a dev command without platform emulation).

Property 'runtime' does not exist on type 'Locals' (Astro). You are using the old Astro.locals.runtime.env pattern, removed in Astro 6. Import env from cloudflare:workers (see the endpoint) and generate the types with npx wrangler types.

no such table: views. You did not apply the schema in the environment you are hitting. Remember that --local and --remote are different databases: run schema.sql in both as appropriate.

It works locally but not in production (or vice versa). It is almost always the same --local vs --remote problem. The local database used by wrangler dev is not the database used by your deployed Worker.

The count increases by two. In development, React Strict Mode (if you use it in Astro islands) or a double mount may trigger two fetch calls. It does not happen in production; if it bothers you in dev, use the localStorage variant.


Conclusion

Recreating the old-school visit counter on Cloudflare is surprisingly direct: one D1 table with one row per article, an endpoint that performs an atomic UPSERT, and a ten-line client script. No separate server is needed, you do not need to enable SSR just for this, and the cost is effectively zero.

The same pattern works for Astro and SvelteKit; the only thing that changes is how each framework gives you the D1 binding: import { env } from "cloudflare:workers" in Astro 6, platform.env in SvelteKit. The database logic —the SQL statement, the atomic increment, the total read— is exactly the same in both.

And, faithful to the original spirit, the counter counts impressions honestly without tracking anyone: a simple number at the bottom of the article, like in the old days.

Appendix: Cloudflare API token permissions

The operations in this guide —creating the D1 database and applying the schema with Wrangler— only touch D1. They do not need Workers, DNS, or any other account permissions.

If you use wrangler login (interactive OAuth flow), Wrangler obtains the permissions for you and you do not need to create a token. This appendix applies when you prefer an explicit API token, for example to run Wrangler in a non-interactive environment or with scoped credentials.

Minimum required permission

The operation is similar to the one described in this post about deploying SvelteKit on Cloudflare.

For D1, the minimum permission is:

Guide operationCommandRequired permission
Create the databasewrangler d1 create viewsAccount → D1 → Edit
Apply the schema (remote)wrangler d1 execute views --remote --file=./schema.sqlAccount → D1 → Edit
Preload/query rows (remote)wrangler d1 execute ... --command "..."Account → D1 → Edit

That is enough. Edit includes reading and writing D1; you do not need a separate read permission. --local operations do not touch the Cloudflare API (they run against a local on-disk copy), so they do not consume token permissions.

Screenshot of the permissions section of a Cloudflare token, showing "Account → D1 → Edit"

Create the token

  1. Go to My Profile → API Tokens and choose Create Token → Create Custom Token.
  2. Under Permissions, add one row: Account · D1 · Edit.
  3. Under Account Resources, select Include → your account (not “All accounts”).
  4. Optional but recommended: set a TTL (expiration date) and, if your network is fixed, restrict it with Client IP Address Filtering.
  5. Create the token and copy it once; Cloudflare will not show it again.

Use the token with Wrangler

Wrangler reads the token from the CLOUDFLARE_API_TOKEN environment variable. For D1 commands, it is also useful to set CLOUDFLARE_ACCOUNT_ID (visible in your account dashboard):

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

References



Previous Post
How to configure a 4.1 audio system on Linux with PipeWire, JamesDSP, and systemd
Next Post
Google Authenticator on two or more devices: a technical guide to avoid losing access.