Search the site
Find pages, services and blog posts
Skip to content
All posts

Privacy-first analytics in SvelteKit with node:sqlite

  • sveltekit
  • svelte
  • sqlite
  • analytics
  • privacy

You can run useful page view analytics in SvelteKit with nothing more than a server hook and node:sqlite. The handle records each page view after the page has rendered, identifies visitors with a salted hash that changes every day, and writes to a SQLite file on a persistent volume. There are no cookies, no client analytics script and no new dependencies. I’ve run this on scottspence.com since December 2025, and it now runs on svelteconsulting.dev and oestechnology.co.uk as well.

The code below is from the svelteconsulting.dev version, which is the leaner of the two.

Why node:sqlite

The scottspence.com version started out on better-sqlite3. In August 2026 a routine upgrade to v13 broke the Coolify build during pnpm install, and I rolled it back. Eleven days later I replaced it with node:sqlite, which ships with Node 24, so there’s no native module to build at all.

The API is close enough that the move was mostly mechanical. DatabaseSync is synchronous like better-sqlite3, and it has a backup() function for consistent snapshots. The database module is a singleton that turns on WAL mode and runs migrations the first time it’s asked for a connection:

1export function get_db() {
2	if (db) return db;
3
4	const file = db_path();
5	fs.mkdirSync(path.dirname(file), { recursive: true });
6
7	db = new DatabaseSync(file, { timeout: 5000 });
8	db.exec('PRAGMA journal_mode = WAL');
9	db.exec('PRAGMA synchronous = NORMAL');
10	run_migrations(db);
11	db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
12
13	return db;
14}

The migrations are plain .sql files, bundled at build time with import.meta.glob('/migrations/*.sql', { query: '?raw' }), so the server doesn’t need to find them on disk at runtime. Each one is applied once inside a transaction and recorded in a migrations table with an applied_at timestamp. That timestamp turns out to be useful later.

Resolve first, then queue

All the capture happens in one handle in hooks.server.ts. It calls resolve before doing anything else, so tracking can never delay the page:

1const track_analytics: Handle = async ({ event, resolve }) => {
2	const response = await resolve(event);
3	const { request, url } = event;
4
5	// Data requests are client navs and hover preloads; real navs are
6	// counted by track_view instead
7	if (
8		building ||
9		event.isDataRequest ||
10		request.method !== 'GET' ||
11		response.status !== 200 ||
12		should_skip_path(url.pathname)
13	) {
14		return response;
15	}
16	// ...queue_page_view(...)
17	return response;
18};

Each condition in that guard is there for a reason:

  • GET with a 200 only. Redirects, 404s, form posts and errors aren’t page views. The spam handle earlier in the sequence already returns a 410 for WordPress and .env probes, so those never get this far anyway.
  • should_skip_path drops anything under /_ or /api/, and any path with a dot in it, which covers static files.
  • event.isDataRequest is the one that caught me out. SvelteKit strips __data.json from event.url, so a data request looks like a normal request for the page. With data-sveltekit-preload-data="hover" on, every hover over a link was counted as a view of the page behind it.

Skipping data requests means client-side navigations aren’t counted by the handle either. Those go through a remote command instead, called from the root layout:

1<script lang="ts">
2	// The server records full page loads; count in-app navigations
3	afterNavigate(({ type, to }) => {
4		if (type === 'enter' || !to) return;
5		track_view({ path: to.url.pathname }).catch(() => {});
6	});
7</script>

The enter navigation is the first page load, which the handle has already recorded. track_view is a command in track.remote.ts that builds the same record from getRequestEvent() and calls the same queue_page_view, so both paths end up in the same table. The same file has a track_click command for mailto links, the contact CTA and outbound links.

A visitor hash that rotates daily

Counting unique visitors without a cookie needs some stable identifier for the length of a visit. I use a SHA-256 of the IP, the user agent, today’s UTC date and a secret salt, truncated to 16 characters:

1export function get_visitor_hash(
2	ip: string | null,
3	user_agent: string | null,
4	salt: string,
5	now = new Date(),
6) {
7	const today = now.toISOString().slice(0, 10);
8	return crypto
9		.createHash('sha256')
10		.update(
11			`${ip || 'unknown'}|${user_agent || 'unknown'}|${today}|${salt}`,
12		)
13		.digest('hex')
14		.slice(0, 16);
15}

Because the date is part of the input, the same person gets a different hash tomorrow, so there’s no way to join their visits across days. The salt lives in an ANALYTICS_SALT environment variable and never goes near the repo, which stops anyone rebuilding hashes from a list of known IPs. The IP that does get stored is anonymised first: the last IPv4 octet, or the last two IPv6 segments, are zeroed.

The trade-off is that “unique visitors” means unique per day. Someone who reads a post on Monday and again on Tuesday counts twice. For a consulting site I’m fine with that.

An in-memory queue, flushed every 5 seconds

Writing to SQLite on every request would work at this traffic level, but it’s needless work in the request path. Instead, the handle pushes to a module-level array:

1// Module-level, so requests only pay for an array push and the DB
2// sees one batched write every few seconds
3const page_views: PageView[] = [];
4const clicks: ClickEvent[] = [];
5
6export function queue_page_view(event: PageView) {
7	page_views.push(event);
8}

A setInterval drains both arrays every 5 seconds and inserts them in a single BEGIN/COMMIT. Referrers matching a blocked_referrer_domains table are nulled on the way in. If the transaction fails, it rolls back and that batch is lost, which is an acceptable failure for analytics. The queue is also flushed on sveltekit:shutdown, so a redeploy doesn’t throw away the last few seconds.

The startup code keeps its timers on globalThis behind a Symbol.for guard, so Vite’s HMR doesn’t stack up a new set of intervals on every save. If the database can’t be opened, it logs the error and returns; analytics must never take the site down.

Bots: user agent first, volume second

parse_user_agent checks each request against a long list of patterns: crawlers, AI agents, social preview fetchers, SEO tools, feed readers, uptime monitors, headless browsers and HTTP clients like curl and python-requests. A missing user agent counts as a bot, because real browsers always send one. So do a few spoofed ones, like a bare Mozilla/5.0 or a Chrome version that’s years out of date.

User agents can be faked, though, and that’s where the scottspence.com version taught me something. On 28 December 2025 one day’s rollup came out at 104,506 views from 2,302 unique visitors, a 45:1 ratio against a normal ratio of around 2:1. The user agent list hadn’t caught it. I fixed the numbers with a migration, got that wrong (it subtracted the inflated views from aggregate tables that had never contained them, and some totals went negative), and wrote a second migration to rebuild the aggregates from the daily data.

The lasting fix was flagging bots by behaviour at rollup time. The svelteconsulting.dev thresholds are:

1// Humans rarely hit one page more than a couple of times a day;
2// anything past these is a scraper with a spoofed UA
3export const BOT_THRESHOLDS = {
4	max_hits_per_path_per_day: 20,
5	max_hits_per_day: 100,
6};

Before a day is rolled up, flag_bots marks every event from any visitor hash that went over either threshold that day. It also flags user agents wrapped in stray quotes, which no real browser sends. The daily hash is what makes this work without tracking anyone: within a day it’s stable enough to spot a scraper, and after that it’s gone.

Rollups and retention without a cron

Raw events are only kept for two days. What stays is two small tables: analytics_daily, with views and unique visitors per path per day, and analytics_all_time, rebuilt from the daily table on each rollup. Bots are excluded from both.

On scottspence.com the rollup, cleanup and backup jobs are triggered by an external scheduler calling a token-protected endpoint. For this site I asked myself what the scheduler was actually for, and the answer was nothing the app couldn’t do itself. The jobs run in-app: once 30 seconds after startup, then every hour.

Running every hour only works if the jobs are idempotent, so every completed run is recorded in a job_runs table:

1CREATE TABLE IF NOT EXISTS job_runs (
2	job TEXT NOT NULL,
3	period TEXT NOT NULL,
4	ran_at INTEGER NOT NULL,
5	PRIMARY KEY (job, period)
6);

run_due_jobs then does whatever is outstanding:

  • Rollup: any UTC day before today that has raw events and no rollup row in job_runs. If the app was down for three days, it catches up on all three.
  • Cleanup: deletes raw events older than two days, and click events older than a year. It runs a VACUUM after a large delete.
  • Backup: one per week, keyed on the Monday’s date, using node:sqlite’s backup() into a backups folder next to the database. It keeps the newest eight.

Restarts and redeploys are safe, because a job that has already run for its period just doesn’t run again.

Private stats with a read-only connection

I don’t want a public stats page on a consulting site, so there’s no UI. There’s a POST /api/ingest endpoint that needs a bearer token, compared with timingSafeEqual, and validates the body with valibot. It can trigger any of the jobs by hand, or run a query:

1/** SELECTs only: runs on a read-only connection, so writes fail */
2export function run_query(
3	db_file: string,
4	sql: string,
5	params: SQLInputValue[] = [],
6) {
7	const ro = new DatabaseSync(db_file, { readOnly: true });
8	try {
9		return ro.prepare(sql).all(...params);
10	} finally {
11		ro.close();
12	}
13}

Opening a separate readOnly connection means I don’t have to parse the SQL to decide whether it’s safe. SQLite refuses the write. The endpoint flushes the queue first, so the answer includes the last few seconds. From a terminal, it looks like this:

1curl -s -X POST https://svelteconsulting.dev/api/ingest \
2	-H 'content-type: application/json' \
3	-H "Authorization: Bearer $INGEST_TOKEN" \
4	-d '{"task": "run_query", "sql": "SELECT * FROM analytics_all_time ORDER BY views DESC LIMIT 20"}'

The content-type header matters. Without it, SvelteKit’s CSRF check treats the request as a cross-site form post and rejects it.

A persistent volume, and proving it persists

The site deploys to Coolify, where each deploy starts a new container. A SQLite file inside the container disappears with it, so the database has to live on a volume. The setup is a volume mount at /app/data and DATABASE_PATH=/app/data/site-data.db, with ANALYTICS_SALT and INGEST_TOKEN set as secrets in Coolify.

Checking that events are arriving doesn’t tell you the volume works. A fresh database in a new container will happily take events too. The proof is the migration timestamp:

1curl -s -X POST https://svelteconsulting.dev/api/ingest \
2	-H 'content-type: application/json' \
3	-H "Authorization: Bearer $INGEST_TOKEN" \
4	-d '{"task": "run_query", "sql": "SELECT name, applied_at FROM migrations"}'

Run it, redeploy, and run it again. If applied_at has changed, the migration ran against an empty database and the volume isn’t mounted where you think it is. If it’s the same, and the older events are still there, the data survived.

The first time I ran this check after adding the volume, it showed a fresh database. The volume was fine. That container was the first one started with the volume attached, so it began on an empty volume. It took one more redeploy, with applied_at unchanged, to prove the data persists. Knowing which container you’re on helps here; I check the uptime reported by /api/health.

Where this leaves me

The svelteconsulting.dev version is around 1,200 lines including tests, with no new dependencies. Fathom still runs alongside it here. What I get is page views, daily unique visitors, referrers, rough device and browser breakdowns, and a handful of click events, all in one SQLite file that I can query from a terminal. Nobody gets a cookie, raw events are gone after two days, and the hash can’t link one day’s visit to the next.

If you want something similar in your SvelteKit app, or want someone to review an analytics setup before it ships, get in touch.