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
sequencealready returns a 410 for WordPress and.envprobes, so those never get this far anyway. should_skip_pathdrops anything under/_or/api/, and any path with a dot in it, which covers static files.event.isDataRequestis the one that caught me out. SvelteKit strips__data.jsonfromevent.url, so a data request looks like a normal request for the page. Withdata-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
rolluprow injob_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
VACUUMafter a large delete. - Backup: one per week, keyed on the Monday’s date, using
node:sqlite’sbackup()into abackupsfolder 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.