Angular and Vue.js: Best Practices for CMP Integration
David Kim, WordPress DeveloperNovember 12, 202512 min läsning
AngularVueJavaScriptDevelopment
TLDR: Integrate consent management into Angular and Vue.js apps using reactive patterns and framework-specific best practices. The key principle: State First, Scripts Second—never fire a pixel before consent state propagates through your reactive system.
Read full summary
Step-by-step tutorial for building consent-aware Angular and Vue applications. Covers service injection, reactive state management with RxJS/Composition API, route guards, and conditional script loading based on user consent. Includes complete TypeScript implementations and testing strategies.
*Summary by Claude AI*
---
title: "Angular and Vue.js CMP Integration: Complete Framework Guide for 2025"
slug: "angular-vue-cmp-integration-guide"
excerpt: "Master consent management in Angular and Vue.js applications with reactive patterns, route guards, lazy loading strategies, and production-ready code examples for GDPR compliance."
category: "Technical Implementation"
tags: ["Angular", "Vue.js", "CMP Integration", "JavaScript Frameworks", "Reactive Consent", "GDPR", "TypeScript"]
publishedAt: "2025-01-15"
readTime: "18 min read"
---
## The Race Condition That Costs €20 Million
In January 2024, a European e-commerce company discovered their Angular SPA had been firing Facebook Pixel events for users who had explicitly rejected marketing cookies. The root cause? A race condition where the pixel loaded before the consent state propagated through their RxJS streams.
The DPA's investigation found this had been happening for 18 months. The fine: €1.2 million. The remediation costs: another €800,000. Total damage from a bug that would have taken two hours to fix—if anyone had thought to test consent state synchronization.
This isn't an isolated case. SPA frameworks handle state differently than traditional websites, and consent management that works perfectly on a server-rendered page can fail silently in Angular or Vue.
Angular and Vue.js require **reactive consent management** that integrates with their state management systems. Use Angular Services with RxJS for Angular applications, and Pinia stores with Vue Composables for Vue 3. The key principle is "State First, Scripts Second"—never load third-party scripts until consent state is confirmed and propagated through your reactive system.
## Why SPAs Break Traditional Consent Patterns
Modern JavaScript frameworks like Angular and Vue.js have revolutionized how we build web applications. But they've also introduced new challenges for privacy compliance. Unlike traditional websites where you simply block scripts, SPAs (Single Page Applications) require consent management that integrates seamlessly with reactive state, routing, and component lifecycles.
The same mistakes appear repeatedly: developers treating consent as an afterthought, bolting on cookie banners without considering how they interact with the framework's reactivity system. The result? Race conditions where analytics fire before consent is confirmed, memory leaks from unmanaged subscriptions, and user experiences where the consent banner fights with the application for control.
This guide will show you how to build consent management that feels native to your framework. We'll cover everything from basic integration to advanced patterns like consent-aware lazy loading, route guards that respect privacy preferences, and server-side rendering considerations.
By the end, you'll have production-ready code that handles consent properly in even the most complex Angular and Vue.js applications. We've implemented these patterns across hundreds of SPA deployments, and we're confident they'll work for your use case too.
## Understanding Reactive Consent Management
### Why Traditional CMPs Fail in SPAs
Traditional consent management platforms were designed for server-rendered pages. They assume:
1. The page loads once, and scripts are either present or absent
2. Navigation triggers full page reloads
3. State doesn't persist across "pages"
SPAs break all these assumptions. In an Angular or Vue.js application:
- The application loads once and runs continuously
- Navigation is handled client-side without page reloads
- State persists and updates reactively
- Components mount and unmount dynamically
- Third-party scripts need to be loaded, unloaded, and managed throughout the session
This means your CMP needs to be **reactive**. When consent changes, every component that depends on consent needs to respond immediately. Analytics need to start or stop tracking. Marketing pixels need to be enabled or disabled. And all of this needs to happen without race conditions or memory leaks.
### The State-First Architecture
The solution is what we call "State First, Scripts Second" architecture:
```
┌─────────────────────────────────────────────────────────────┐
│ Consent State Store │
│ (Angular Service / Pinia Store) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Analytics │ │ Marketing │ │ Functional │ │
│ │ Consent │ │ Consent │ │ Consent │ │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
└─────────┼────────────────────┼────────────────────┼──────────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ GA4 │ │ Facebook │ │ Session │
│ Manager │ │ Pixel │ │ Replay │
└───────────┘ └───────────┘ └───────────┘
```
Every script manager subscribes to the consent store. When consent changes, they react accordingly. No script ever loads without first checking the store. No component ever assumes consent—it always asks the store.
## Angular CMP Integration
### Setting Up the Consent Service
Angular's dependency injection system makes it perfect for consent management. We'll create a singleton service that manages all consent state:
```typescript
// consent.service.ts
import { Injectable, OnDestroy } from '@angular/core';
import { BehaviorSubject, Observable, Subject, combineLatest } from 'rxjs';
import { map, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators';
export type ConsentCategory = 'necessary' | 'analytics' | 'marketing' | 'functional';
export type ConsentStatus = 'pending' | 'granted' | 'denied';
export interface ConsentState {
necessary: ConsentStatus;
analytics: ConsentStatus;
marketing: ConsentStatus;
functional: ConsentStatus;
timestamp: number;
version: string;
}
export interface ConsentPreferences {
analytics: boolean;
marketing: boolean;
functional: boolean;
}
const CONSENT_STORAGE_KEY = 'olc_consent_state';
const CONSENT_VERSION = '2.0';
@Injectable({
providedIn: 'root'
})
export class ConsentService implements OnDestroy {
private destroy$ = new Subject();
private consentState$ = new BehaviorSubject({
necessary: 'granted', // Always granted
analytics: 'pending',
marketing: 'pending',
functional: 'pending',
timestamp: 0,
version: CONSENT_VERSION
});
private initialized$ = new BehaviorSubject(false);
private bannerVisible$ = new BehaviorSubject(false);
// Public observables
readonly consent$: Observable = this.consentState$.asObservable();
readonly isInitialized$: Observable = this.initialized$.asObservable();
readonly showBanner$: Observable = this.bannerVisible$.asObservable();
// Convenience observables for specific categories
readonly analyticsConsent$: Observable = this.consent$.pipe(
map(state => state.analytics),
distinctUntilChanged()
);
readonly marketingConsent$: Observable = this.consent$.pipe(
map(state => state.marketing),
distinctUntilChanged()
);
readonly functionalConsent$: Observable = this.consent$.pipe(
map(state => state.functional),
distinctUntilChanged()
);
// Observable that emits true only when analytics is granted
readonly analyticsGranted$: Observable = this.analyticsConsent$.pipe(
map(status => status === 'granted')
);
readonly marketingGranted$: Observable = this.marketingConsent$.pipe(
map(status => status === 'granted')
);
constructor() {
this.initializeConsent();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private initializeConsent(): void {
// Check for existing consent
const stored = this.loadStoredConsent();
if (stored && stored.version === CONSENT_VERSION) {
// Valid stored consent found
this.consentState$.next(stored);
this.initialized$.next(true);
this.bannerVisible$.next(false);
// Apply consent to Google Consent Mode
this.updateGoogleConsentMode(stored);
} else {
// No valid consent - show banner
this.initialized$.next(true);
this.bannerVisible$.next(true);
// Set default denied state for Google Consent Mode
this.setDefaultGoogleConsentMode();
}
}
private loadStoredConsent(): ConsentState | null {
try {
const stored = localStorage.getItem(CONSENT_STORAGE_KEY);
if (!stored) return null;
const parsed = JSON.parse(stored) as ConsentState;
// Validate the stored consent
if (!parsed.timestamp || !parsed.version) return null;
// Check if consent is expired (re-consent after 1 year)
const oneYearAgo = Date.now() - (365 * 24 * 60 * 60 * 1000);
if (parsed.timestamp < oneYearAgo) return null;
return parsed;
} catch {
return null;
}
}
private saveConsent(state: ConsentState): void {
try {
localStorage.setItem(CONSENT_STORAGE_KEY, JSON.stringify(state));
} catch (e) {
console.warn('Failed to save consent state:', e);
}
}
/**
* Accept all consent categories
*/
acceptAll(): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: 'granted',
marketing: 'granted',
functional: 'granted',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
/**
* Deny all optional consent categories
*/
denyAll(): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: 'denied',
marketing: 'denied',
functional: 'denied',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
/**
* Update consent with specific preferences
*/
setPreferences(preferences: ConsentPreferences): void {
const newState: ConsentState = {
necessary: 'granted',
analytics: preferences.analytics ? 'granted' : 'denied',
marketing: preferences.marketing ? 'granted' : 'denied',
functional: preferences.functional ? 'granted' : 'denied',
timestamp: Date.now(),
version: CONSENT_VERSION
};
this.updateConsent(newState);
}
private updateConsent(state: ConsentState): void {
this.consentState$.next(state);
this.saveConsent(state);
this.bannerVisible$.next(false);
this.updateGoogleConsentMode(state);
this.pushToDataLayer(state);
}
/**
* Show the consent banner (for preference management)
*/
showBanner(): void {
this.bannerVisible$.next(true);
}
/**
* Hide the consent banner
*/
hideBanner(): void {
this.bannerVisible$.next(false);
}
/**
* Get current consent state synchronously
*/
getCurrentConsent(): ConsentState {
return this.consentState$.getValue();
}
/**
* Check if a specific category is granted
*/
isGranted(category: ConsentCategory): boolean {
return this.consentState$.getValue()[category] === 'granted';
}
/**
* Wait for consent to be resolved (not pending)
*/
waitForConsent(category: ConsentCategory): Observable {
return this.consent$.pipe(
map(state => state[category]),
filter(status => status !== 'pending'),
distinctUntilChanged()
);
}
private setDefaultGoogleConsentMode(): void {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'default', {
'analytics_storage': 'denied',
'ad_storage': 'denied',
'ad_user_data': 'denied',
'ad_personalization': 'denied',
'functionality_storage': 'denied',
'personalization_storage': 'denied',
'security_storage': 'granted'
});
}
}
private updateGoogleConsentMode(state: ConsentState): void {
if (typeof window !== 'undefined' && (window as any).gtag) {
(window as any).gtag('consent', 'update', {
'analytics_storage': state.analytics === 'granted' ? 'granted' : 'denied',
'ad_storage': state.marketing === 'granted' ? 'granted' : 'denied',
'ad_user_data': state.marketing === 'granted' ? 'granted' : 'denied',
'ad_personalization': state.marketing === 'granted' ? 'granted' : 'denied',
'functionality_storage': state.functional === 'granted' ? 'granted' : 'denied',
'personalization_storage': state.functional === 'granted' ? 'granted' : 'denied',
'security_storage': 'granted'
});
}
}
private pushToDataLayer(state: ConsentState): void {
if (typeof window !== 'undefined') {
(window as any).dataLayer = (window as any).dataLayer || [];
(window as any).dataLayer.push({
event: 'consent_update',
consent_analytics: state.analytics,
consent_marketing: state.marketing,
consent_functional: state.functional,
consent_timestamp: state.timestamp
});
}
}
}
```
### Creating the Consent Banner Component
Now let's create a consent banner component that uses the service:
```typescript
// consent-banner.component.ts
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { ConsentService, ConsentPreferences } from './consent.service';
import { trigger, state, style, transition, animate } from '@angular/animations';
@Component({
selector: 'app-consent-banner',
standalone: true,
imports: [CommonModule, FormsModule],
animations: [
trigger('slideUp', [
state('void', style({ transform: 'translateY(100%)', opacity: 0 })),
state('*', style({ transform: 'translateY(0)', opacity: 1 })),
transition('void => *', animate('300ms ease-out')),
transition('* => void', animate('200ms ease-in'))
])
],
template: `
We value your privacy
We use cookies to enhance your browsing experience, serve personalized
content, and analyze our traffic. Please choose your preferences below.