Tutorial

Zero-Bundle Analytics: Integrating CounterAPI with Qwik in 2025

Learn how to implement efficient analytics in Qwik applications using CounterAPI without compromising Qwik's zero-bundle-size philosophy.

JO

Tutorial Expert

• 4 min read

CounterAPI Qwik Analytics Performance Resumability

Why CounterAPI Is Perfect for Qwik Applications

Qwik has revolutionized web development with its resumable architecture and near-zero JavaScript delivery. When adding analytics to a Qwik application, you need a solution that maintains these performance benefits while providing actionable insights.

CounterAPI offers an ideal match for Qwik's philosophy: lightweight, efficient, and focused on precisely what you need without unnecessary bloat.

In this guide, we'll explore how to integrate CounterAPI with Qwik for both client and server-side analytics.

Setting Up CounterAPI in Your Qwik Project

First, install the CounterAPI package:

npm install counterapi@latest

Server-Side Integration with Qwik City

Qwik City's server-side rendering capabilities make it perfect for tracking page views without impacting client-side performance.

Creating a CounterAPI Service

Let's create a utility file to manage our counter instances:

// src/utils/counter.ts
import { Counter } from 'counterapi';
import { server$ } from '@builder.io/qwik-city';

// For server-side operations
export const serverCounter = server$(async () => {
  return new Counter({
    workspace: process.env.COUNTER_WORKSPACE || '',
    accessToken: process.env.COUNTER_TOKEN || '',
  });
});

// For client-side use when needed
export function createClientCounter() {
  return new Counter({
    workspace: import.meta.env.PUBLIC_COUNTER_WORKSPACE,
    accessToken: import.meta.env.PUBLIC_COUNTER_TOKEN,
  });
}

// Server-side tracking utility
export const trackEvent = server$(async function(eventName: string) {
  try {
    const counter = await serverCounter();
    const result = await counter.up(eventName);
    return result.value;
  } catch (error) {
    console.error('Analytics error:', error);
    return null;
  }
});

Configure your environment variables in .env:

COUNTER_WORKSPACE=your-workspace
COUNTER_TOKEN=server-token
PUBLIC_COUNTER_WORKSPACE=your-workspace
PUBLIC_COUNTER_TOKEN=public-token

Page View Tracking with Qwik Routes

Use Qwik's route loaders to track page views server-side:

// src/routes/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { serverCounter } from '~/utils/counter';

export const usePageViews = routeLoader$(async () => {
  try {
    const counter = await serverCounter();
    const result = await counter.up('home-page-view');
    return result.value;
  } catch (error) {
    console.error('Failed to track page view:', error);
    return 'unavailable';
  }
});

export default component$(() => {
  const pageViews = usePageViews();

  return (
    <div class="home-page">
      <h1>Welcome to My Qwik Site!</h1>
      <p>This page has been viewed {pageViews.value} times.</p>
      {/* Rest of your page content */}
    </div>
  );
});

Tracking User Interactions

For client-side interactions, Qwik's unique approach requires thoughtful implementation:

Creating a Reusable Counter Component

// src/components/counter-button/counter-button.tsx
import { component$, useSignal, useVisibleTask$, useTask$ } from '@builder.io/qwik';
import { trackEvent } from '~/utils/counter';

interface CounterButtonProps {
  eventName: string;
  showCount?: boolean;
  label: string;
}

export default component$((props: CounterButtonProps) => {
  const count = useSignal<number | null>(null);
  const loading = useSignal(true);
  const clicked = useSignal(false);
  
  // Load initial count if needed
  useVisibleTask$(async ({ track }) => {
    track(() => props.eventName);
    
    if (props.showCount) {
      try {
        const value = await trackEvent(`${props.eventName}-get`);
        count.value = value;
      } catch (error) {
        console.error('Error fetching count:', error);
      } finally {
        loading.value = false;
      }
    } else {
      loading.value = false;
    }
  });
  
  return (
    <button
      onClick$={async () => {
        if (!clicked.value) {
          clicked.value = true;
          const newCount = await trackEvent(props.eventName);
          if (props.showCount && newCount !== null) {
            count.value = newCount;
          }
        }
      }}
      class={{
        'counter-button': true,
        'clicked': clicked.value
      }}
    >
      {props.label}
      {props.showCount && !loading.value && 
        <span class="count">({count.value ?? 0})</span>
      }
    </button>
  );
});

Using the Counter Button

// src/routes/features/index.tsx
import { component$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { serverCounter } from '~/utils/counter';
import CounterButton from '~/components/counter-button/counter-button';

// Track page view
export const usePageView = routeLoader$(async () => {
  try {
    const counter = await serverCounter();
    await counter.up('features-page-view');
  } catch (error) {
    console.error('Analytics error:', error);
  }
  return true;
});

export default component$(() => {
  usePageView();
  
  const features = [
    { id: 1, name: 'Instant Loading', description: 'Zero delay with resumability' },
    { id: 2, name: 'Progressive Hydration', description: 'Load only what\'s needed' },
    { id: 3, name: 'Optimal Bundle Size', description: 'Minimal JavaScript shipped' }
  ];

  return (
    <div class="features-page">
      <h1>Features</h1>
      
      <div class="features-grid">
        {features.map((feature) => (
          <div key={feature.id} class="feature-card">
            <h3>{feature.name}</h3>
            <p>{feature.description}</p>
            <CounterButton
              eventName={`feature-${feature.id}-interest`}
              label="I'm interested"
              showCount={true}
            />
          </div>
        ))}
      </div>
    </div>
  );
});

Form Submission Analytics

Track form submissions with CounterAPI and Qwik's form actions:

// src/routes/contact/index.tsx
import { component$, useSignal } from '@builder.io/qwik';
import { routeAction$, zod$, z, Form } from '@builder.io/qwik-city';
import { serverCounter } from '~/utils/counter';

export const useContactAction = routeAction$(
  async (data) => {
    try {
      // Process form data here
      
      // Track successful submission
      const counter = await serverCounter();
      await counter.up('contact-form-success');
      
      return {
        success: true,
        message: 'Thank you for your message!'
      };
    } catch (error) {
      console.error('Form submission error:', error);
      
      // Track error
      try {
        const counter = await serverCounter();
        await counter.up('contact-form-error');
      } catch (analyticsError) {
        console.error('Analytics error:', analyticsError);
      }
      
      return {
        success: false,
        message: 'An error occurred. Please try again.'
      };
    }
  },
  zod$({
    name: z.string().min(1, 'Name is required'),
    email: z.string().email('Valid email is required'),
    message: z.string().min(10, 'Message must be at least 10 characters')
  })
);

export default component$(() => {
  const action = useContactAction();
  
  return (
    <div class="contact-page">
      <h1>Contact Us</h1>
      
      {action.value?.success ? (
        <div class="success-message">
          <h3>Message Sent!</h3>
          <p>{action.value.message}</p>
        </div>
      ) : (
        <Form action={action} class="contact-form">
          <div class="form-group">
            <label for="name">Name</label>
            <input 
              id="name"
              name="name" 
              type="text"
              class={{ error: !!action.value?.fieldErrors?.name }}
            />
            {action.value?.fieldErrors?.name && (
              <p class="error-message">{action.value.fieldErrors.name}</p>
            )}
          </div>
          
          <div class="form-group">
            <label for="email">Email</label>
            <input 
              id="email"
              name="email"
              type="email"
              class={{ error: !!action.value?.fieldErrors?.email }}
            />
            {action.value?.fieldErrors?.email && (
              <p class="error-message">{action.value.fieldErrors.email}</p>
            )}
          </div>
          
          <div class="form-group">
            <label for="message">Message</label>
            <textarea
              id="message" 
              name="message"
              rows={5}
              class={{ error: !!action.value?.fieldErrors?.message }}
            ></textarea>
            {action.value?.fieldErrors?.message && (
              <p class="error-message">{action.value.fieldErrors.message}</p>
            )}
          </div>
          
          <button type="submit">Send Message</button>
          
          {action.value?.message && !action.value.success && (
            <div class="error-banner">{action.value.message}</div>
          )}
        </Form>
      )}
    </div>
  );
});

Global Navigation Tracking with Qwik Layout

Track all navigation events by adding a global tracker in your layout:

// src/routes/layout.tsx
import { component$, Slot, useVisibleTask$, useSignal } from '@builder.io/qwik';
import { useLocation } from '@builder.io/qwik-city';
import { trackEvent } from '~/utils/counter';

export default component$(() => {
  const location = useLocation();
  const previousPath = useSignal<string | null>(null);
  
  // Track page navigation
  useVisibleTask$(({ track }) => {
    track(() => location.url.pathname);
    
    const currentPath = location.url.pathname;
    
    // Don't track the initial page load (that's handled by route loaders)
    // Only track navigation changes
    if (previousPath.value !== null) {
      const formattedPath = currentPath === '/' 
        ? 'home' 
        : currentPath.substring(1).replace(/\//g, '-');
        
      trackEvent(`navigate-to-${formattedPath}`);
    }
    
    previousPath.value = currentPath;
  });
  
  return (
    <div class="app-container">
      <header>
        <nav>
          {/* Your navigation */}
        </nav>
      </header>
      
      <main>
        <Slot />
      </main>
      
      <footer>
        {/* Your footer */}
      </footer>
    </div>
  );
});

Creating an Analytics Dashboard

Build a simple dashboard to view your analytics data:

// src/routes/admin/analytics/index.tsx
import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';
import { routeLoader$ } from '@builder.io/qwik-city';
import { serverCounter } from '~/utils/counter';

// Fetch initial metrics on the server
export const useAnalyticsData = routeLoader$(async () => {
  // In a real app, add authentication check here
  
  const metricKeys = [
    'home-page-view',
    'features-page-view',
    'contact-form-success',
    'contact-form-error',
    'feature-1-interest',
    'feature-2-interest',
    'feature-3-interest'
  ];
  
  try {
    const counter = await serverCounter();
    const metrics = {};
    
    for (const key of metricKeys) {
      const result = await counter.get(key);
      metrics[key] = result.value;
    }
    
    return { metrics, metricKeys };
  } catch (error) {
    console.error('Failed to fetch analytics:', error);
    return { metrics: {}, metricKeys };
  }
});

export default component$(() => {
  const analyticsData = useAnalyticsData();
  const metrics = useSignal(analyticsData.value.metrics);
  const refreshing = useSignal(false);
  
  return (
    <div class="analytics-dashboard">
      <header>
        <h1>Analytics Dashboard</h1>
        <button 
          onClick$={async () => {
            refreshing.value = true;
            
            try {
              const counter = await serverCounter();
              const updatedMetrics = {};
              
              for (const key of analyticsData.value.metricKeys) {
                const result = await counter.get(key);
                updatedMetrics[key] = result.value;
              }
              
              metrics.value = updatedMetrics;
            } catch (error) {
              console.error('Failed to refresh metrics:', error);
            } finally {
              refreshing.value = false;
            }
          }}
          disabled={refreshing.value}
        >
          {refreshing.value ? 'Refreshing...' : 'Refresh Metrics'}
        </button>
      </header>
      
      <div class="metrics-grid">
        {Object.entries(metrics.value).map(([key, value]) => (
          <div key={key} class="metric-card">
            <h3>{key.replace(/-/g, ' ')}</h3>
            <p class="metric-value">{value}</p>
          </div>
        ))}
      </div>
    </div>
  );
});

Advanced: Creating an Endpoint for Client-Side Data

For authenticated clients that need to fetch analytics:

// src/routes/api/analytics/index.ts
import { serverCounter } from '~/utils/counter';
import type { RequestHandler } from '@builder.io/qwik-city';

export const onGet: RequestHandler = async ({ json, query, env }) => {
  // In a real app, implement authentication here
  const apiKey = query.get('apiKey');
  if (apiKey !== env.get('ANALYTICS_API_KEY')) {
    return json({ error: 'Unauthorized' }, 401);
  }
  
  const counterNames = query.get('counters')?.split(',') || [];
  
  if (counterNames.length === 0) {
    return json({ error: 'No counters specified' }, 400);
  }
  
  try {
    const counter = await serverCounter();
    const results = {};
    
    for (const name of counterNames) {
      const result = await counter.get(name);
      results[name] = result.value;
    }
    
    return json({ results });
  } catch (error) {
    console.error('Analytics API error:', error);
    return json({ error: 'Failed to fetch analytics' }, 500);
  }
};

Conclusion

By integrating CounterAPI with Qwik, you get the best of both worlds: Qwik's revolutionary resumability and performance paired with CounterAPI's efficient analytics.

This approach maintains Qwik's zero-bundle-size philosophy by:

  1. Performing most analytics operations server-side
  2. Using Qwik's built-in server$ function for efficient client-server communication
  3. Minimizing client-side tracking code with the optional component pattern
  4. Leveraging Qwik City's routing and form handling for built-in analytics hooks

The result is a powerful analytics solution that doesn't compromise Qwik's performance benefits.

For more information about CounterAPI and its features, check out the official documentation.