Tutorial

Server-Side Analytics with Angular Universal and CounterAPI

Learn how to implement robust analytics in your Angular Universal applications using CounterAPI for both server and client-side tracking.

EL

Tutorial Expert

• 5 min read

CounterAPI Angular Angular Universal Analytics SSR

Why Angular Universal Needs a Robust Analytics Solution

Angular Universal has become a cornerstone for building SEO-friendly, performance-optimized Angular applications in 2025. By rendering on the server first, it provides faster initial page loads and better indexing for search engines.

However, implementing analytics in a server-rendered Angular application presents unique challenges. CounterAPI offers a perfect solution with its versatile API that works seamlessly in both server and browser environments.

In this guide, we'll explore how to integrate CounterAPI into your Angular Universal application for comprehensive analytics.

Setting Up CounterAPI in Angular Universal

First, install the CounterAPI package:

npm install counterapi@latest

Creating a CounterAPI Service

Let's build a service that works in both server and browser contexts:

// src/app/services/counter.service.ts
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
import { Counter } from 'counterapi';
import { environment } from '../../environments/environment';

@Injectable({
  providedIn: 'root'
})
export class CounterService {
  private browserCounter: Counter | null = null;
  private serverCounter: Counter | null = null;

  constructor(@Inject(PLATFORM_ID) private platformId: Object) {
    // Initialize server counter immediately
    if (isPlatformServer(this.platformId)) {
      this.serverCounter = new Counter({
        workspace: environment.counterApi.workspace,
        accessToken: environment.counterApi.serverToken
      });
    }
    // Browser counter is lazily initialized
  }

  // Get the appropriate counter instance
  private getCounter(): Counter {
    if (isPlatformBrowser(this.platformId)) {
      if (!this.browserCounter) {
        this.browserCounter = new Counter({
          workspace: environment.counterApi.workspace,
          accessToken: environment.counterApi.browserToken
        });
      }
      return this.browserCounter;
    } else {
      return this.serverCounter!;
    }
  }

  // Track an event (increment counter)
  async trackEvent(eventName: string): Promise<number> {
    try {
      const counter = this.getCounter();
      const result = await counter.up(eventName);
      return result.value;
    } catch (error) {
      console.error('Analytics error:', error);
      return -1; // Error indicator
    }
  }

  // Get current count
  async getCount(eventName: string): Promise<number> {
    try {
      const counter = this.getCounter();
      const result = await counter.get(eventName);
      return result.value;
    } catch (error) {
      console.error('Analytics error:', error);
      return -1; // Error indicator
    }
  }
}

Environment Configuration

Add your CounterAPI configuration to your environments:

// src/environments/environment.ts
export const environment = {
  production: false,
  counterApi: {
    workspace: 'your-workspace',
    browserToken: 'your-public-token',
    serverToken: 'your-private-token'
  }
};

And for production:

// src/environments/environment.prod.ts
export const environment = {
  production: true,
  counterApi: {
    workspace: 'your-workspace',
    browserToken: 'your-public-token',
    serverToken: 'your-private-token'
  }
};

Server-Side Page View Tracking

Track page views during server-side rendering:

// src/app/services/analytics.service.ts
import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformServer } from '@angular/common';
import { NavigationEnd, Router } from '@angular/router';
import { filter } from 'rxjs/operators';
import { CounterService } from './counter.service';

@Injectable({
  providedIn: 'root'
})
export class AnalyticsService {
  constructor(
    private counterService: CounterService,
    private router: Router,
    @Inject(PLATFORM_ID) private platformId: Object
  ) {
    // Track navigation events
    this.router.events.pipe(
      filter(event => event instanceof NavigationEnd)
    ).subscribe((event: NavigationEnd) => {
      this.trackPageView(event.urlAfterRedirects);
    });
  }

  // Initialize analytics for the current page
  async initPageTracking(url: string): Promise<void> {
    // Only track on server for initial page load
    if (isPlatformServer(this.platformId)) {
      await this.trackPageView(url);
    }
  }

  // Track page view
  async trackPageView(url: string): Promise<void> {
    const path = this.formatPath(url);
    await this.counterService.trackEvent(`page-${path}`);
  }

  // Format the URL to a counter-friendly name
  private formatPath(url: string): string {
    // Remove leading slash and replace remaining slashes with dashes
    let path = url.startsWith('/') ? url.substring(1) : url;
    
    // Handle query params and hash
    path = path.split('?')[0].split('#')[0];
    
    // Convert slashes to dashes
    path = path.replace(/\//g, '-');
    
    // Handle empty path (home page)
    return path || 'home';
  }
}

Implementing Server-Side Tracking in App Initialization

// src/app/app.module.ts
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { BrowserModule, BrowserTransferStateModule } from '@angular/platform-browser';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { AnalyticsService } from './services/analytics.service';
import { REQUEST } from '@nguniversal/express-engine/tokens';

// Factory for initializing analytics on server-side
export function initializeAnalytics(
  analyticsService: AnalyticsService,
  request: any
) {
  return () => {
    // Get the requested URL
    const url = request?.url || '/';
    return analyticsService.initPageTracking(url);
  };
}

@NgModule({
  declarations: [AppComponent],
  imports: [
    BrowserModule.withServerTransition({ appId: 'your-app' }),
    BrowserTransferStateModule,
    RouterModule.forRoot([
      // Your routes
    ])
  ],
  providers: [
    {
      provide: APP_INITIALIZER,
      useFactory: initializeAnalytics,
      deps: [AnalyticsService, [new Inject(REQUEST)]],
      multi: true
    }
  ],
  bootstrap: [AppComponent]
})
export class AppModule { }

Client-Side Interaction Tracking

Create an analytics directive for tracking user interactions:

// src/app/directives/track-event.directive.ts
import { Directive, Input, HostListener } from '@angular/core';
import { CounterService } from '../services/counter.service';

@Directive({
  selector: '[trackEvent]'
})
export class TrackEventDirective {
  @Input('trackEvent') eventName!: string;

  constructor(private counterService: CounterService) {}

  @HostListener('click')
  onClick() {
    if (this.eventName) {
      this.counterService.trackEvent(this.eventName)
        .catch(error => console.error('Tracking error:', error));
    }
  }
}

Use the directive in your components:

// src/app/components/feature-card/feature-card.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-feature-card',
  template: `
    <div class="feature-card">
      <h3>{{ title }}</h3>
      <p>{{ description }}</p>
      
      <button 
        [trackEvent]="'feature-' + id + '-details-view'"
        (click)="showDetails = !showDetails"
      >
        {{ showDetails ? 'Hide details' : 'View details' }}
      </button>
      
      <div *ngIf="showDetails" class="details">
        {{ details }}
      </div>
    </div>
  `,
  styles: [`
    .feature-card {
      padding: 1.5rem;
      border-radius: 8px;
      margin-bottom: 1rem;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }
    
    .details {
      margin-top: 1rem;
      padding: 1rem;
      background-color: #f9f9f9;
      border-radius: 4px;
    }
  `]
})
export class FeatureCardComponent {
  @Input() id!: number;
  @Input() title!: string;
  @Input() description!: string;
  @Input() details!: string;
  
  showDetails = false;
}

Creating a View Counter Component

Build a reusable component for displaying view counts:

// src/app/components/view-counter/view-counter.component.ts
import { Component, Input, OnInit } from '@angular/core';
import { CounterService } from '../../services/counter.service';

@Component({
  selector: 'app-view-counter',
  template: `
    <div class="view-counter">
      <ng-container *ngIf="loading">Loading count...</ng-container>
      <ng-container *ngIf="!loading">{{ count }} views</ng-container>
    </div>
  `,
  styles: [`
    .view-counter {
      display: inline-block;
      padding: 0.25rem 0.75rem;
      background-color: #f0f0f0;
      border-radius: 1rem;
      font-size: 0.875rem;
    }
  `]
})
export class ViewCounterComponent implements OnInit {
  @Input() counterName!: string;
  count: number = 0;
  loading: boolean = true;

  constructor(private counterService: CounterService) {}

  ngOnInit() {
    this.fetchCount();
  }

  async fetchCount() {
    try {
      this.count = await this.counterService.getCount(this.counterName);
    } catch (error) {
      console.error('Error fetching count:', error);
    } finally {
      this.loading = false;
    }
  }
}

Use it in your blog post component:

// src/app/pages/blog-post/blog-post.component.ts
import { Component, OnInit } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
import { CounterService } from '../../services/counter.service';
import { BlogService } from '../../services/blog.service';

@Component({
  selector: 'app-blog-post',
  template: `
    <article *ngIf="post">
      <header>
        <h1>{{ post.title }}</h1>
        <div class="meta">
          <span>{{ post.date | date:'mediumDate' }}</span>
          <app-view-counter [counterName]="'post-' + post.slug"></app-view-counter>
        </div>
      </header>
      
      <div class="content" [innerHTML]="post.content"></div>
      
      <div class="actions">
        <button [trackEvent]="'like-post-' + post.slug">
          👍 Like this post
        </button>
        <button [trackEvent]="'share-post-' + post.slug">
          📤 Share
        </button>
      </div>
    </article>
  `
})
export class BlogPostComponent implements OnInit {
  post: any;

  constructor(
    private route: ActivatedRoute,
    private blogService: BlogService,
    private counterService: CounterService
  ) {}

  async ngOnInit() {
    // Get the post slug from the route
    const slug = this.route.snapshot.paramMap.get('slug');
    
    if (slug) {
      // Load post data
      this.post = await this.blogService.getPost(slug);
      
      // Track post view (in addition to page view)
      if (this.post) {
        this.counterService.trackEvent(`post-${slug}`)
          .catch(error => console.error('Error tracking post view:', error));
      }
    }
  }
}

Creating an Admin Dashboard

Build a simple analytics dashboard:

// src/app/pages/admin/analytics-dashboard/analytics-dashboard.component.ts
import { Component, OnInit } from '@angular/core';
import { CounterService } from '../../../services/counter.service';

interface MetricData {
  name: string;
  displayName: string;
  value: number;
  loading: boolean;
}

@Component({
  selector: 'app-analytics-dashboard',
  template: `
    <div class="dashboard">
      <header>
        <h1>Analytics Dashboard</h1>
        <button (click)="refreshMetrics()" [disabled]="isRefreshing">
          {{ isRefreshing ? 'Refreshing...' : 'Refresh' }}
        </button>
      </header>
      
      <div class="metrics-grid">
        <div class="metric-card" *ngFor="let metric of metrics">
          <h3>{{ metric.displayName }}</h3>
          <p class="metric-value" [class.loading]="metric.loading">
            {{ metric.loading ? 'Loading...' : metric.value }}
          </p>
        </div>
      </div>
    </div>
  `,
  styles: [`
    .dashboard {
      padding: 1.5rem;
    }
    
    header {
      display: flex;
      justify-content: space-between;
      align-items: center;
      margin-bottom: 2rem;
    }
    
    .metrics-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
      gap: 1.5rem;
    }
    
    .metric-card {
      padding: 1.5rem;
      border-radius: 8px;
      background-color: #f9f9f9;
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
    }
    
    .metric-value {
      font-size: 2.5rem;
      font-weight: bold;
      margin: 0.5rem 0 0;
    }
    
    .loading {
      opacity: 0.5;
    }
  `]
})
export class AnalyticsDashboardComponent implements OnInit {
  metrics: MetricData[] = [
    { name: 'page-home', displayName: 'Home Page Views', value: 0, loading: true },
    { name: 'page-blog', displayName: 'Blog Page Views', value: 0, loading: true },
    { name: 'page-features', displayName: 'Features Page Views', value: 0, loading: true },
    { name: 'feature-1-details-view', displayName: 'Feature 1 Details', value: 0, loading: true },
    { name: 'feature-2-details-view', displayName: 'Feature 2 Details', value: 0, loading: true },
    { name: 'contact-form-submission', displayName: 'Contact Form Submissions', value: 0, loading: true }
  ];
  
  isRefreshing = false;

  constructor(private counterService: CounterService) {}

  ngOnInit() {
    this.loadMetrics();
  }

  async loadMetrics() {
    for (const metric of this.metrics) {
      metric.loading = true;
      try {
        metric.value = await this.counterService.getCount(metric.name);
      } catch (error) {
        console.error(`Error loading ${metric.name}:`, error);
      } finally {
        metric.loading = false;
      }
    }
  }

  async refreshMetrics() {
    this.isRefreshing = true;
    await this.loadMetrics();
    this.isRefreshing = false;
  }
}

Form Submission Tracking

Track form submissions with reactive forms:

// src/app/components/contact-form/contact-form.component.ts
import { Component } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { CounterService } from '../../services/counter.service';

@Component({
  selector: 'app-contact-form',
  template: `
    <form [formGroup]="contactForm" (ngSubmit)="onSubmit()">
      <div class="form-group">
        <label for="name">Name</label>
        <input type="text" id="name" formControlName="name">
        <div *ngIf="submitted && f.name.errors" class="error">
          Name is required
        </div>
      </div>
      
      <div class="form-group">
        <label for="email">Email</label>
        <input type="email" id="email" formControlName="email">
        <div *ngIf="submitted && f.email.errors" class="error">
          <div *ngIf="f.email.errors.required">Email is required</div>
          <div *ngIf="f.email.errors.email">Email must be valid</div>
        </div>
      </div>
      
      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" formControlName="message" rows="5"></textarea>
        <div *ngIf="submitted && f.message.errors" class="error">
          Message is required
        </div>
      </div>
      
      <button type="submit" [disabled]="loading">
        {{ loading ? 'Sending...' : 'Send Message' }}
      </button>
      
      <div *ngIf="success" class="success-message">
        Message sent successfully!
      </div>
      
      <div *ngIf="error" class="error-message">
        {{ error }}
      </div>
    </form>
  `,
  styles: [`
    .form-group {
      margin-bottom: 1.5rem;
    }
    
    label {
      display: block;
      margin-bottom: 0.5rem;
    }
    
    input, textarea {
      width: 100%;
      padding: 0.75rem;
      border: 1px solid #ddd;
      border-radius: 4px;
    }
    
    .error {
      color: #e53935;
      font-size: 0.875rem;
      margin-top: 0.25rem;
    }
    
    .success-message {
      margin-top: 1rem;
      padding: 0.75rem;
      background-color: #e8f5e9;
      color: #2e7d32;
      border-radius: 4px;
    }
    
    .error-message {
      margin-top: 1rem;
      padding: 0.75rem;
      background-color: #ffebee;
      color: #c62828;
      border-radius: 4px;
    }
  `]
})
export class ContactFormComponent {
  contactForm: FormGroup;
  loading = false;
  submitted = false;
  success = false;
  error = '';

  constructor(
    private formBuilder: FormBuilder,
    private counterService: CounterService
  ) {
    this.contactForm = this.formBuilder.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      message: ['', Validators.required]
    });
  }

  // Easy access to form fields
  get f() { return this.contactForm.controls; }

  async onSubmit() {
    this.submitted = true;
    
    // Stop if form is invalid
    if (this.contactForm.invalid) {
      await this.counterService.trackEvent('contact-form-validation-error');
      return;
    }
    
    this.loading = true;
    this.error = '';
    
    try {
      // Send the form data (implementation not shown)
      await this.submitFormData(this.contactForm.value);
      
      // Track successful submission
      await this.counterService.trackEvent('contact-form-submission');
      
      this.success = true;
      this.contactForm.reset();
      this.submitted = false;
    } catch (error) {
      this.error = 'An error occurred while submitting the form. Please try again.';
      
      // Track submission error
      await this.counterService.trackEvent('contact-form-error');
    } finally {
      this.loading = false;
    }
  }
  
  private async submitFormData(data: any): Promise<void> {
    // Implement your form submission logic here
    // This would typically be an HTTP call to your backend
    return new Promise((resolve) => {
      // Simulating API call
      setTimeout(() => resolve(), 1000);
    });
  }
}

Conclusion

By integrating CounterAPI with Angular Universal, you get comprehensive analytics that work seamlessly across server and client environments. This implementation allows you to:

  1. Track page views server-side for accurate analytics even when JavaScript is disabled
  2. Record user interactions client-side with minimal code using directives
  3. Display real-time metrics to users with dedicated components
  4. Build a comprehensive admin dashboard for monitoring site usage

The service-based approach ensures your analytics code is clean, reusable, and properly respects Angular Universal's server/client architecture.

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