Profile Picture

Yevgen`s official homepage

Yevgen Somochkin

"ESSO"

CTO Yieldy

Building the Future of AI & Automation | Full-stack & DevOps Architect | n8n | Scalable, Data-driven Web & Mobile Apps | Salesforce x2 certified

Home city logoHamburg, Germany

Profile Picture

Yevgen Somochkin

Need help?

Book a call

Software Engineer & Architect

Hamburg, Germany

How I Built Server-Side Analytics Without Cookies and Got Rid of the Annoying Banner

Nov 13, 2025
Web Development

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

FeatureGoogle AnalyticsMy System
Page views
Sessions
Duration
Geolocation✅ (MaxMind)
Devices✅ (ua-parser)
Cookie banner❌ Required✅ NOT NEEDED!
Performance❌ ~40KB JS✅ 0KB
Data control❌ Google✅ My server
Privacy❌ Questionable✅ 100%

Conclusions

Pros:

  1. No cookie banner — clean UX
  2. Full control — my data, my server
  3. GDPR-friendly — no consent needed
  4. 0ms on frontend — doesn't affect speed
  5. Customization — any metrics you want

Cons:

  1. Not for everyone — if you need conversion funnels, A/B tests → stick with GA
  2. Maintenance — you write it, you fix it
  3. 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! 👇