Server-side analytics using SvelteKit hooks + SQLite. No cookies, GDPR-friendly.
hooks.server.tscf-ipcountry header)pnpm install
cp .env.example .env
Generate a salt for visitor hashing:
# Linux/macOS
echo "ANALYTICS_SALT=$(openssl rand -hex 16)" > .env
# For rollup endpoint
echo "ROLLUP_TOKEN=$(openssl rand -hex 16)" >> .env
Start dev server:
pnpm dev
Database auto-creates at data/analytics.db on first request.
Automatic. Any HTML page request is tracked.
<script>
import { track } from '$lib/analytics.remote';
</script>
<button
onclick={() =>
track({ name: 'signup_click', props: { plan: 'pro' } })}
>
Sign Up
</button>
<script>
import { get_active_visitors } from '$lib/analytics-queries.remote';
import { country_to_flag } from '$lib/analytics.helpers';
let data = get_active_visitors({ limit: 10 });
</script>
<p>
{data.current.total} visitors
{#if data.current.bots > 0}(+{data.current.bots} š¤){/if}
</p>
<p>
{#each data.current.countries as c}
{c.count}{country_to_flag(c.country)}
{/each}
</p>
<script>
import { get_active_visitors } from '$lib/analytics-queries.remote';
let data = get_active_visitors({ limit: 10 });
// IMPORTANT: Use data.refresh(), NOT get_active_visitors().refresh()
// Creating a new instance resets the data structure
setInterval(() => data.refresh(), 30000);
</script>
CREATE TABLE analytics_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
visitor_hash TEXT NOT NULL, -- daily rotating hash (IP+UA+date+salt)
event_type TEXT NOT NULL, -- 'page_view' | 'custom'
event_name TEXT, -- custom event name
path TEXT NOT NULL,
referrer TEXT,
user_agent TEXT,
ip TEXT, -- anonymized (last octet zeroed)
country TEXT, -- ISO 3166-1 alpha-2 (e.g. 'GB', 'US')
browser TEXT, -- parsed from user_agent
device_type TEXT, -- 'desktop' | 'mobile' | 'tablet'
os TEXT, -- parsed from user_agent
is_bot INTEGER, -- 1 = bot, 0 = human
props TEXT, -- JSON
created_at INTEGER NOT NULL -- unix ms
);
src/
āāā hooks.server.ts # auto page view tracking
āāā lib/
ā āāā analytics.remote.ts # track() command + UA parsing
ā āāā analytics.helpers.ts # pure query functions + flag emoji
ā āāā analytics-queries.remote.ts # query remote functions
ā āāā server/
ā āāā db.ts # database connection
ā āāā schema.sql # table schema
ā āāā rollup-schema.sql # summary tables schema
āāā routes/
ā āāā api/rollup/+server.ts # rollup job endpoint
Problem: Polling components that call
get_active_visitors({ limit: 10 }).refresh() create NEW remote
function instances instead of refreshing existing data.
Fix: Store the remote function result and call .refresh() on it:
<!-- WRONG -->
<script>
let data = get_active_visitors({ limit: 10 });
setInterval(() => get_active_visitors({ limit: 10 }).refresh(), 30000);
</script>
<!-- CORRECT -->
<script>
let data = get_active_visitors({ limit: 10 });
setInterval(() => data.refresh(), 30000);
</script>
Problem: Using page.url.origin during SSR returns
https://example.com/ instead of the actual domain.
Fix: Use relative paths instead of absolute URLs:
<!-- WRONG -->
<a href="{$page.url.origin}/posts/{slug}">Read more</a>
<!-- CORRECT -->
<a href="/posts/{slug}">Read more</a>
Problem: Closing the SQLite connection after each request breaks the singleton pattern and causes "database closed" errors.
Fix: Don't close singleton database connections. The connection stays open for the lifetime of the process:
// db.ts - CORRECT
export const db = new Database(DB_PATH);
db.pragma('journal_mode = WAL');
// No close() call - singleton stays open
Problem: In dev mode, HMR can cause database reconnects that invalidate cached prepared statements.
Fix: Create fresh statements instead of caching at module level:
// WRONG - cached statement may become stale
export const insert_event = db.prepare(`INSERT INTO...`);
// CORRECT - fresh statement each call
export const get_insert_statement = () => {
return db.prepare(`INSERT INTO...`);
};
Problem: Bots inflate analytics numbers significantly.
Fix: Filter bots from all queries with
(is_bot = 0 OR is_bot IS NULL):
SELECT COUNT(DISTINCT visitor_hash) as count
FROM analytics_events
WHERE created_at > ? AND (is_bot = 0 OR is_bot IS NULL)
The OR is_bot IS NULL handles older data before bot detection was
added.
Problem: Need geographic data without GeoIP databases.
Fix: Use CDN headers:
cf-ipcountryx-vercel-ip-countryProblem: Query functions tightly coupled to database make testing difficult.
Fix: Extract query logic to pure functions with database as parameter:
// analytics.helpers.ts - testable pure functions
export function query_active_visitors(
db: DatabaseClient, // can be mocked
options: { limit?: number }
): ActiveVisitorsResult { ... }
// analytics-queries.remote.ts - wires up the database
export const get_active_visitors = query(
schema,
({ limit }) => query_active_visitors(db, { limit })
);
-- page views by path (excluding bots)
SELECT path, COUNT(*) as views
FROM analytics_events
WHERE event_type = 'page_view' AND (is_bot = 0 OR is_bot IS NULL)
GROUP BY path
ORDER BY views DESC;
-- unique visitors per day (excluding bots)
SELECT DATE(created_at/1000, 'unixepoch') as day,
COUNT(DISTINCT visitor_hash) as visitors
FROM analytics_events
WHERE is_bot = 0 OR is_bot IS NULL
GROUP BY day;
-- visitors on page right now (last 5 min, excluding bots)
SELECT COUNT(DISTINCT visitor_hash) as active
FROM analytics_events
WHERE path = '/'
AND created_at > (strftime('%s', 'now') * 1000 - 300000)
AND (is_bot = 0 OR is_bot IS NULL);
-- country breakdown
SELECT country, COUNT(DISTINCT visitor_hash) as visitors
FROM analytics_events
WHERE country IS NOT NULL AND (is_bot = 0 OR is_bot IS NULL)
GROUP BY country
ORDER BY visitors DESC;
For historical data, run the rollup job daily to aggregate events into summary tables:
curl -X POST https://yoursite.com/api/rollup \
-H "Content-Type: application/json" \
-d '{"token": "your-rollup-token"}'
This aggregates analytics_events into:
analytics_monthly - page views by monthanalytics_yearly - page views by yearanalytics_all_time - total stats per path| Variable | Description |
|---|---|
ANALYTICS_SALT |
Secret for visitor hash generation |
ROLLUP_TOKEN |
Auth token for rollup endpoint |
DATABASE_PATH |
Custom database path (optional) |