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:
navigator.sendBeacon()— guaranteed delivery even when page closes- 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! 👇




Comments
Loading comments...