Grav is a flat-file CMS built with PHP and Twig templates. This guide covers multiple methods to install Google Analytics 4 on your Grav site, from simple plugins to custom template integration.
Before You Begin
Create a GA4 Property
- Go to Google Analytics
- Create a new GA4 property
- Note your Measurement ID (format:
G-XXXXXXXXXX)
Choose Your Implementation Method
- Grav Plugin: Easiest, no code required
- Twig Template: More control, theme-level integration
- Google Tag Manager: Recommended for advanced tracking
Admin Access Required
- You'll need access to Grav Admin Panel or file system
- FTP/SSH access for manual plugin installation
Method 1: Google Analytics Plugin (Recommended)
The simplest method uses the official Grav Google Analytics plugin.
Install Plugin via Admin Panel
Navigate to Admin Panel
- Go to
https://yoursite.com/admin - Log in with your credentials
- Go to
Install Plugin
- Click Plugins in sidebar
- Click + Add button
- Search for "Google Analytics"
- Click Install on "Google Analytics" plugin
Configure Plugin
- Go to Plugins → Google Analytics
- Enter your Tracking ID:
G-XXXXXXXXXX - Enable Status: Toggle to ON
- Configure options:
- Position:
headorbody(recommended:head) - Async Loading: Enable (recommended)
- Anonymize IP: Enable if required by GDPR
- Debug Mode: Enable for testing
- Position:
- Click Save
Install Plugin via GPM (Command Line)
# SSH into your server
cd /path/to/grav
# Install via Grav Package Manager
bin/gpm install google-analytics
# Or install manually
cd user/plugins
git clone https://github.com/escopecz/grav-ganalytics.git google-analytics
Manual Configuration
Edit the plugin configuration file:
# user/config/plugins/google-analytics.yaml
enabled: true
trackingId: 'G-XXXXXXXXXX'
position: 'head'
async: true
anonymizeIp: true
debugMode: false
objectName: 'ga'
forceSsl: true
blockedIps: []
blockedPaths: []
Plugin Features
The Google Analytics plugin provides:
- Automatic page view tracking
- IP anonymization for GDPR compliance
- Block specific IPs from tracking (admin IPs)
- Block specific paths from tracking
- Debug mode for testing
- Async/defer script loading
Method 2: Twig Template Integration
For complete control, add GA4 directly to your theme templates.
Add to Base Template
{# user/themes/your-theme/templates/partials/base.html.twig #}
<!DOCTYPE html>
<html lang="{{ grav.language.getActive ?: 'en' }}">
<head>
{% block head %}
<meta charset="utf-8" />
<title>{% if page.title %}{{ page.title }} | {% endif %}{{ site.title }}</title>
{# Google Analytics 4 #}
{% if not grav.config.plugins['google-analytics'].enabled %}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', {
'anonymize_ip': true,
'cookie_flags': 'SameSite=None;Secure'
});
</script>
{% endif %}
{{ assets.css()|raw }}
{% endblock %}
</head>
<body>
{% block content %}{% endblock %}
{{ assets.js()|raw }}
</body>
</html>
Add to Header Partial
If your theme uses a header partial:
{# user/themes/your-theme/templates/partials/head.html.twig #}
<meta charset="utf-8" />
<title>{% if page.title %}{{ page.title|e('html') }} | {% endif %}{{ site.title|e('html') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{# Google Analytics 4 #}
{% set ga_id = grav.config.site.google_analytics_id %}
{% if ga_id %}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ ga_id }}', {
'anonymize_ip': {{ grav.config.site.ga_anonymize_ip ? 'true' : 'false' }},
'cookie_domain': '{{ grav.uri.host() }}',
'send_page_view': true
});
</script>
{% endif %}
Configure in site.yaml
# user/config/site.yaml
title: My Grav Site
default_lang: en
google_analytics_id: 'G-XXXXXXXXXX'
ga_anonymize_ip: true
Method 3: Custom Plugin Development
Create your own minimal GA4 plugin:
Create Plugin Structure
# Create plugin directory
mkdir -p user/plugins/ga4-custom
# Create required files
touch user/plugins/ga4-custom/ga4-custom.php
touch user/plugins/ga4-custom/ga4-custom.yaml
Plugin Code
<?php
// user/plugins/ga4-custom/ga4-custom.php
namespace Grav\Plugin;
use Grav\Common\Plugin;
use Grav\Common\Page\Page;
class Ga4CustomPlugin extends Plugin
{
public static function getSubscribedEvents()
{
return [
'onPluginsInitialized' => ['onPluginsInitialized', 0]
];
}
public function onPluginsInitialized()
{
if ($this->isAdmin()) {
return;
}
$this->enable([
'onPageContentRaw' => ['onPageContentRaw', 0]
]);
}
public function onPageContentRaw()
{
$config = $this->config->get('plugins.ga4-custom');
if (!$config['enabled'] || empty($config['tracking_id'])) {
return;
}
$tracking_id = $config['tracking_id'];
$anonymize_ip = $config['anonymize_ip'] ? 'true' : 'false';
$ga4_script = <<<HTML
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={$tracking_id}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{$tracking_id}', {
'anonymize_ip': {$anonymize_ip}
});
</script>
HTML;
$this->grav['assets']->addInlineJs($ga4_script, ['position' => 'head']);
}
}
Plugin Configuration
# user/plugins/ga4-custom/ga4-custom.yaml
enabled: true
tracking_id: 'G-XXXXXXXXXX'
anonymize_ip: true
Method 4: Page-Level Tracking
Add GA4 to specific pages using page frontmatter:
Page Frontmatter
---
title: Special Landing Page
google_analytics: true
ga_tracking_id: 'G-YYYYYYYYYY'
---
# Page content here
Template Logic
{# Check if page has custom GA ID #}
{% if page.header.google_analytics %}
{% set ga_id = page.header.ga_tracking_id ?: grav.config.site.google_analytics_id %}
{% if ga_id %}
<!-- Google tag (gtag.js) -->
<script async src="https://www.googletagmanager.com/gtag/js?id={{ ga_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ ga_id }}');
</script>
{% endif %}
{% endif %}
Advanced Configuration
Track Custom Dimensions
<script>
gtag('config', 'G-XXXXXXXXXX', {
'custom_map': {
'dimension1': 'page_type',
'dimension2': 'author',
'dimension3': 'category'
}
});
gtag('event', 'page_view', {
'page_type': '{{ page.template() }}',
'author': '{{ page.header.author ?: "Unknown" }}',
'category': '{{ page.taxonomy.category|first ?: "Uncategorized" }}'
});
</script>
Enhanced Measurement Settings
gtag('config', 'G-XXXXXXXXXX', {
'send_page_view': true,
'anonymize_ip': true,
'cookie_domain': 'auto',
'cookie_flags': 'SameSite=None;Secure',
'allow_google_signals': false,
'allow_ad_personalization_signals': false,
// Enhanced measurement
'link_attribution': true,
'linker': {
'domains': ['example.com', 'subdomain.example.com']
}
});
Track Modular Pages
{# For modular pages with multiple content sections #}
{% if page.modular %}
<script>
gtag('event', 'modular_page_view', {
'page_location': '{{ grav.uri.url(true) }}',
'page_title': '{{ page.title }}',
'module_count': {{ page.collection()|length }}
});
</script>
{% endif %}
GDPR Compliance
Cookie Consent Integration
{# Conditional loading based on consent #}
<script>
// Check if user has consented
function hasGAConsent() {
return localStorage.getItem('ga_consent') === 'true';
}
// Load GA4 only if consent given
if (hasGAConsent()) {
var script = document.createElement('script');
script.async = true;
script.src = 'https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX';
document.head.appendChild(script);
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'G-XXXXXXXXXX', {
'anonymize_ip': true
});
}
</script>
Opt-Out Functionality
<script>
// GA4 opt-out
var gaProperty = 'G-XXXXXXXXXX';
var disableStr = 'ga-disable-' + gaProperty;
if (document.cookie.indexOf(disableStr + '=true') > -1) {
window[disableStr] = true;
}
function gaOptout() {
document.cookie = disableStr + '=true; expires=Thu, 31 Dec 2099 23:59:59 UTC; path=/';
window[disableStr] = true;
alert('Google Analytics tracking has been disabled.');
}
</script>
{# Opt-out link #}
<a href="javascript:gaOptout()">Disable Google Analytics</a>
Multi-Language Sites
Language-Specific Tracking
{% set active_lang = grav.language.getActive ?: grav.config.site.default_lang %}
<script>
gtag('config', 'G-XXXXXXXXXX', {
'language': '{{ active_lang }}',
'content_group': 'lang_{{ active_lang }}'
});
</script>
Multiple Properties for Different Languages
{% set ga_ids = {
'en': 'G-XXXXXXXXXX',
'es': 'G-YYYYYYYYYY',
'fr': 'G-ZZZZZZZZZZ'
} %}
{% set current_ga_id = ga_ids[active_lang] ?: ga_ids['en'] %}
<script async src="https://www.googletagmanager.com/gtag/js?id={{ current_ga_id }}"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '{{ current_ga_id }}');
</script>
Verification & Testing
1. Check GA4 Realtime Reports
- Open GA4 → Reports → Realtime
- Navigate your Grav site
- Verify page views appear within 30 seconds
2. Use Browser Console
// Check if GA4 is loaded
console.log(window.gtag);
console.log(window.dataLayer);
// Test event manually
gtag('event', 'test_event', { test_parameter: 'test_value' });
3. View Page Source
- Right-click → View Page Source
- Search for
googletagmanager.com/gtag/js - Verify your Measurement ID appears
4. Use GA4 DebugView
Enable debug mode in plugin or add to template:
gtag('config', 'G-XXXXXXXXXX', {
'debug_mode': true
});
Then check Admin → DebugView in GA4.
5. Test Different Page Types
- Homepage (default.html.twig)
- Blog posts (item.html.twig)
- Modular pages (modular.html.twig)
- Custom templates
Common Issues
GA4 Not Loading
Possible Causes:
- Plugin disabled
- Incorrect Tracking ID
- Ad blocker active
- JavaScript errors in console
Solution:
- Check plugin status in Admin Panel
- Verify Tracking ID format:
G-XXXXXXXXXX - Check browser console for errors
- Test in incognito mode
Duplicate Tracking Codes
Cause: Both plugin and template include GA4.
Solution: Use conditional check:
{% if not grav.config.plugins['google-analytics'].enabled %}
{# Your GA4 code here #}
{% endif %}
Tracking Not Working in Admin
Expected: GA4 should not track admin pages.
Solution: Most plugins exclude admin by default. To verify:
if ($this->isAdmin()) {
return; // Don't track admin pages
}
Cache Issues
Symptom: Changes not appearing.
Solution:
# Clear Grav cache
bin/grav clear-cache
# Or via Admin Panel
Admin → Configuration → Clear Cache
Performance Optimization
Defer Loading
<script defer src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXXXXX"></script>
Conditional Loading
{# Only load on production #}
{% if grav.config.system.environment == 'production' %}
{# GA4 code here #}
{% endif %}
Exclude Admin IPs
# user/config/plugins/google-analytics.yaml
blockedIps:
- '192.168.1.100'
- '10.0.0.1'
Next Steps
- Configure GA4 Events - Track custom events
- Install GTM - For easier tag management
- Set up Data Layer - Structure your tracking data
For general GA4 concepts, see Google Analytics 4 Guide.