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.
Tutorial Expert
• 5 min read
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:
- Track page views server-side for accurate analytics even when JavaScript is disabled
- Record user interactions client-side with minimal code using directives
- Display real-time metrics to users with dedicated components
- 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.