How I Built Server-Side Analytics Without Cookies and Got Rid of the Annoying Banner
The Problem: Cookie Banners Kill UX
Every modern website greets visitors with an annoying cookie banner. Google Analytics, Yandex.Metrica, Facebook Pixel — they all require user consent under GDPR. The banner covers content, distracts attention, and honestly, nobody needs it.
I decided to go radical: completely abandon client-side trackers and build my own server-side analytics that:
- ✅ Doesn't require cookies
- ✅ Doesn't violate GDPR
- ✅ Doesn't require user consent
- ✅ Completely under my control
- ✅ Has zero impact on frontend performance
Architecture Overview
Tech Stack:
- Next.js 15 (frontend)
- Strapi v5 (CMS and data storage)
- MaxMind GeoLite2 (IP geolocation)
- ua-parser-js (User-Agent parsing)
- JSONL buffer (event buffering)
How It Works:
User's Browser ↓ (usePageView hook) [POST /api/analytics] ← Collects metrics ↓ [analytics-buffer/events.jsonl] ← Event buffer ↓ (every 10 minutes) [Strapi Cron Job] → [POST /api/analytics/process] ↓ [MaxMind + ua-parser-js] ← Data enrichment ↓ [Strapi Database] ← Storage ↓ [Analytics Dashboard] ← Visualization
Step 1: System Design
GDPR Requirements
The most important thing — understand what can be stored:
- ✅ Page path (
)/blog/post-123 - ✅ Visit duration
- ✅ Scroll depth
- ✅ Browser, OS, device (from User-Agent)
- ✅ Country, city (from IP, but not the IP itself!)
- ✅ Screen dimensions, viewport
What cannot:
- ❌ IP addresses directly
- ❌ Cookies for tracking
- ❌ Personal data without consent
Session ID Without Cookies
Instead of cookies, I use
sessionStorage + crypto.randomUUID():
const getSessionId = () => { let sessionId = sessionStorage.getItem('analytics-session'); if (!sessionId) { sessionId = crypto.randomUUID(); sessionStorage.setItem('analytics-session', sessionId); } return sessionId; };
SessionStorage:
- Clears when tab closes
- Not sent to server
- Doesn't require GDPR consent
Step 2: Client-Side Implementation
React Hook for Tracking
Created a custom
usePageView hook with smart features:
export function usePageView() { const pathname = usePathname(); const pathnameRef = useRef(pathname); useEffect(() => { const sessionId = getSessionId(); const startTime = Date.now(); let maxScrollDepth = 0; // Track scrolling const handleScroll = () => { const scrollPercent = Math.round( (window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)) * 100 ); maxScrollDepth = Math.max(maxScrollDepth, scrollPercent); }; // Send on page leave const sendAnalytics = () => { const duration = Math.round((Date.now() - startTime) / 1000); navigator.sendBeacon('/api/analytics', JSON.stringify({ path: pathnameRef.current, sessionId, duration, scrollDepth: maxScrollDepth, // ... other metrics })); }; // Page Visibility API document.addEventListener('visibilitychange', () => { if (document.hidden) sendAnalytics(); }); return () => sendAnalytics(); }, []); }
Key Points:
— guaranteed delivery even when page closesnavigator.sendBeacon()- Page Visibility API — precise moment of leaving
- No pathname dependency in useEffect — avoids unnecessary calls
Step 3: Server-Side Processing
JSONL Buffering
Instead of direct DB writes, I use a buffer:
export async function appendToBuffer(event: AnalyticsEvent) { const bufferPath = path.join(process.cwd(), 'analytics-buffer', 'events.jsonl'); const line = JSON.stringify(event) + '\n'; await fs.appendFile(bufferPath, line, 'utf-8'); }
Benefits:
- 0ms delay for users
- Batched DB requests
- Resilience to failures
Data Enrichment
Each event is enriched server-side:
// IP → Geolocation (MaxMind) const ip = getClientIP(request.headers); const geo = await getGeoFromIP(ip); // {country: "US", city: "New York"} // User-Agent → Browser, OS, Device const uaData = parseUserAgent(userAgent); // {browser: "Chrome", os: "macOS", device: "desktop"} const analyticsEvent = { ...clientData, country: geo.country, // IP NOT STORED! city: geo.city, browser: uaData.browser, device: uaData.device, };
IP address never reaches the database — critical for GDPR.
Step 4: Batch Processor
Strapi Cron Job
Every 10 minutes, Strapi calls the processor:
// config/server.ts export default { cron: { enabled: true, tasks: { '*/10 * * * *': async ({ strapi }) => { const response = await fetch('http://frontend:3000/api/analytics/process', { method: 'POST', headers: { 'X-Cron-Secret': process.env.CRON_SECRET, }, }); }, }, }, };
Processor with Batching
export async function POST(request: NextRequest) { // Security: IP whitelist + Secret if (!isIPAllowed(clientIP)) return 403; if (cronSecret !== CRON_SECRET) return 401; const events = await readBuffer(); // Read JSONL // Send in batches of 100 for (let i = 0; i < events.length; i += BATCH_SIZE) { const batch = events.slice(i, i + BATCH_SIZE); await Promise.allSettled( batch.map(event => fetch(`${STRAPI_URL}/api/page-views`, { method: 'POST', body: JSON.stringify({ data: event }), }) ) ); } await clearBuffer(); // Clear after success }
Step 5: Analytics Dashboard in Strapi
Created a custom page in Strapi Admin:
// src/admin/app.ts export default { register(app) { (app.addMenuLink as any)({ to: '/plugins/analytics', icon: BarChart, intlLabel: { id: 'Analytics', defaultMessage: 'Analytics' }, Component: async () => { return import('./pages/Analytics'); }, }); }, };
Dashboard Shows:
Statistics:
- Total Page Views
- Unique Sessions
- Avg. Duration
Tables:
- Top Pages (by views)
- Devices (desktop/mobile/tablet)
- Top Countries
- Recent Page Views (last 20)
Filters:
- Last 7 Days
- Last 30 Days
- All Time
Results
Performance
Client-side overhead: 0ms - Tracking hook: ~0.1ms (negligible) - sendBeacon: async, doesn't block Server-side processing: batched every 10 minutes - 100 events → ~500ms processing - User doesn't feel it
GDPR Compliance
✅ No IP storage — only country/city
✅ No cookies — only sessionStorage
✅ No consent needed — data is anonymous
✅ Right to deletion — easy to delete by sessionId
✅ Transparency — all data on our server
What I Got Instead of Google Analytics
| Feature | Google Analytics | My System |
|---|---|---|
| Page views | ✅ | ✅ |
| Sessions | ✅ | ✅ |
| Duration | ✅ | ✅ |
| Geolocation | ✅ | ✅ (MaxMind) |
| Devices | ✅ | ✅ (ua-parser) |
| Cookie banner | ❌ Required | ✅ NOT NEEDED! |
| Performance | ❌ ~40KB JS | ✅ 0KB |
| Data control | ✅ My server | |
| Privacy | ❌ Questionable | ✅ 100% |
Conclusions
Pros:
- No cookie banner — clean UX
- Full control — my data, my server
- GDPR-friendly — no consent needed
- 0ms on frontend — doesn't affect speed
- Customization — any metrics you want
Cons:
- Not for everyone — if you need conversion funnels, A/B tests → stick with GA
- Maintenance — you write it, you fix it
- Scale — for sites with millions of visitors, different solutions needed
Next Steps
Planning to add:
- Real-time dashboard via WebSockets
- Export to CSV/JSON
- Alerts on anomalies (e.g., sudden traffic drop)
- Heatmaps for popular pages
TL;DR: Built server-side analytics without cookies and cookie banners. GDPR-compliant, 0ms overhead, full data control. Google Analytics no longer needed for basic statistics.
Question to readers: Are you ready to abandon GA in favor of your own analytics? Write in the comments! 👇

