The theme ships a CORE-OWNED consent mechanism that gates analytics script loading behind user consent. This document covers configuration, consent states, the script injection lifecycle, the trackEvent() event taxonomy, custom snippet setup, consent revocation, and environment-aware behavior.
For provider configuration fields (googleTagManager, googleAnalyticsGA4, consentRequired, consent, customSnippets), see Site Configuration.
Who this is for
- Implementors configuring analytics and consent for a site
- Developers extending event tracking or adding custom snippets
- AI agents wiring components into the analytics layer
Consent Banner
Component: src/core/components/base/ConsentBanner.astro (CORE-OWNED)
The consent banner is a fixed bottom bar with three equally-weighted buttons: Accept, Reject, and Required Only. It appears on first visit when consentRequired is true and at least one analytics provider or custom snippet is configured.
Visibility Rules
| Condition | Banner visible? |
|---|---|
consentRequired: false | No — scripts load immediately |
consentRequired: true, no providers or snippets configured | No — nothing to consent to |
consentRequired: true, providers configured, no stored consent | Yes |
consentRequired: true, providers configured, valid consent exists | No |
consentRequired: true, providers configured, expired consent | Yes — re-prompt |
Configuration
All banner text is configurable in site.ts under analytics.consent:
analytics: {
consentRequired: true,
consent: {
message: "This site uses cookies...",
acceptLabel: "Accept",
rejectLabel: "Reject",
requiredOnlyLabel: "Required Only",
policyUrl: "/privacy/",
policyLinkLabel: "Privacy Policy",
expirationDays: 365,
cookieClearingPatterns: ["_ga", "_gid", "_gat", "_gcl"],
},
},
Consent States
Consent is stored in localStorage under the key analytics-consent as a JSON object:
{ "status": "accepted", "timestamp": 1710000000000 }
Three statuses are supported:
| Status | Scripts load? | Description |
|---|---|---|
accepted | Yes | User clicked Accept — all configured scripts inject |
rejected | No | User clicked Reject — no scripts inject |
required-only | No | User clicked Required Only — no analytics scripts inject; required scripts (theme mode) are unaffected |
Expiration
Consent expires after expirationDays (default: 365). On page load, the banner checks timestamp + (expirationDays × 86400000) < Date.now(). If expired, the stored consent is cleared and the banner re-appears.
Script Injection Lifecycle
Analytics.astro uses a two-script architecture:
- Inline script (
is:inline): Runs synchronously in<head>. Setswindow.__analyticsConfig, validates custom snippet IDs, and in production mode performs consent-gated dynamic injection. - Module script: Imports
trackEvent()fromsrc/lib/analytics.tsand assigns it towindow.trackEvent.
Injection Flow (Production)
- Inline script reads consent state from
localStorage - If
consentRequiredisfalse→ inject all configured scripts immediately - If
consentRequiredistrueand consent isaccepted→ inject all configured scripts - If
consentRequiredistrueand no valid consent → register aconsent-grantedevent listener; scripts inject when the user accepts via the banner (no page reload needed) - GTM takes precedence over GA4 — when both are enabled, only GTM loads
- Custom snippets inject after GTM/GA4 in config array order
Dev Mode
In development (import.meta.env.DEV):
- No analytics scripts inject, regardless of consent state or config
trackEvent()logs toconsole.debuginstead of pushing todataLayer- The consent banner still follows its normal visibility rules (config-driven, not suppressed)
Use npm run build && npm run preview to verify script injection behavior.
Event Taxonomy
The theme defines 8 shared events routed through trackEvent():
| Event | Source | Payload |
|---|---|---|
cta_click | CallToAction.astro | { label, href } |
form_submit | ContactForm.astro, NewsletterForm.astro | { formId: "contact" | "newsletter" } |
search_opened | Search modal | { source: "trigger" | "shortcut" } |
search_submitted | Search modal/page | { queryLength } |
search_results_viewed | Search modal/page | { resultCount } |
search_result_clicked | Search modal/page | { resultIndex } |
search_no_results | Search modal/page | { queryLength } |
page_view is left to GTM/GA4 auto-tracking — manual emission would double-count.
Using trackEvent()
// In a module script:
window.trackEvent("cta_click", { label: "Get Started", href: "/contact/" });
trackEvent() is consent-aware:
- In dev mode → logs to
console.debug - In prod, consent required and not accepted → silent no-op
- In prod, no analytics targets configured → silent no-op
- Otherwise → pushes
{ event: name, ...payload }towindow.dataLayer
Site-specific events can use any string name — trackEvent() accepts arbitrary event names for extensibility.
Search Events
Search events are exposed via src/lib/search-events.ts with a stable public API:
import {
trackSearchSubmitted,
trackSearchResultClicked,
} from "@/lib/search-events";
trackSearchSubmitted(query.length);
trackSearchResultClicked(0);
These functions call trackEvent() internally. Search payloads use queryLength/resultCount/resultIndex — no raw query text is captured.
Custom Snippets
Custom snippets support non-Google tracking and marketing scripts. They follow the same consent gate as GTM/GA4.
customSnippets: [
{ id: "hotjar", src: "https://static.hotjar.com/c/hotjar-XXXXX.js", location: "head" },
{ id: "inline-pixel", content: "console.log('pixel loaded')", location: "body-end" },
],
Each entry requires a unique id (deduplication key) and exactly one of src (external URL) or content (inline script). The location field controls injection into <head> or before </body>.
Duplicate id values are logged as console.warn in both dev and prod — only the first matching entry injects per page load.
CSP note: The theme does not set or enforce Content Security Policy headers. If your hosting environment uses CSP, ensure script-src permits any external snippet URLs and unsafe-inline if using inline content snippets. CSP is a hosting/deployment concern.
Consent Revocation
Component: src/core/components/base/ConsentReset.astro (CORE-OWNED)
The ConsentReset component provides a “Reset Cookie Preferences” button for placement on the privacy policy page. It renders whenever consentRequired is true (server-side gate).
Revocation Sequence
- Clears
localStorageconsent state - Clears cookies matching
cookieClearingPatternsprefixes (default:_ga,_gid,_gat,_gcl) - Triggers a full page reload
After reload, the consent banner re-appears (no stored consent) and no analytics scripts inject.
Cookie Clearing
Cookie clearing uses prefix matching — "_ga" matches _ga, _ga_XXXXX (GA4 client ID cookies), and _gat. Cookies are cleared with path=/ and both bare and dotted domain to cover common cookie configurations.
To clear cookies set by custom snippets, add their name prefixes to cookieClearingPatterns:
consent: {
cookieClearingPatterns: ["_ga", "_gid", "_gat", "_gcl", "_hj"],
},
Placement
Import in any MDX page:
import ConsentReset from "@core/components/base/ConsentReset.astro";
## Manage Your Consent
<ConsentReset />
consent.policyUrl vs forms.privacyPolicyUrl
Both default to "/privacy/" and typically point to the same page, but they are independent config values:
analytics.consent.policyUrl— drives the consent banner’s privacy policy linkforms.privacyPolicyUrl— drives the consent checkbox links on ContactForm and NewsletterForm
Update both if your privacy policy page is at a different URL.