Google Analytics 4 (GA4)
Description
Section titled “Description”Google Analytics 4 is used for web analytics and conversion tracking across web applications.
App Coverage
Section titled “App Coverage”| App | Integrated |
|---|---|
| UK | ✅ |
| DE | ✅ |
Integration
Section titled “Integration”GA4 can be integrated via Google tag (gtag.js) API. An overview of the integration methods can be found via the links below:
Debug Tools
Section titled “Debug Tools”An overview of the debugging tools can be found via the links below:
- GA4 DebugView
- Chrome DevTools Network tab (
collect,g/collect) - Google Tag Assistant
- Consent Debug
Before integrating GA4, note the following:
- Default consent should be configured before GA4 loads.
- Campaign attribution needs no code.
gtag.jssends the full page URL aspage_location, and GA4 parsesutm_source,utm_medium,utm_campaign,utm_term,utm_content,utm_id,gclidand the other click identifiers out of it into its own traffic-source dimensions. Passing them again as event parameters creates custom parameters that do not feed those dimensions. - Event names and parameters should follow a consistent schema of the recommended events.
Plugin Usage Guide
Section titled “Plugin Usage Guide”The @releafuk/analytics package provides a custom GA4 plugin through the ga4Provider() factory function. GA4 is integrated directly through gtag.js, without requiring GTM.
Configuration
Section titled “Configuration”The ga4Provider() factory accepts the config parameter that should satisfy the Ga4Config type:
type GtagConfig = { send_page_view?: boolean; debug_mode?: boolean; linker?: { domains: string[]; decorate_forms?: boolean }; cookie_domain?: string; cookie_prefix?: string; cookie_expires?: number; cookie_flags?: string; allow_google_signals?: boolean; allow_ad_personalization_signals?: boolean; [key: string]: unknown;};
type Ga4Config = { measurementIds: string[]; enabled?: boolean; consentMode?: "basic" | "advanced"; waitForUpdate?: number; nonce?: string; gtagName?: string; dataLayerName?: string; gtagConfig?: GtagConfig;};| Option | Required | Default | Description |
|---|---|---|---|
measurementIds | Yes | - | One or more GA4 measurement IDs. See Multiple measurement IDs. An empty array makes the provider a no-op. |
enabled | No | true | Controls whether the provider loads and initializes GA4. Use false to keep the provider registered but disabled for an environment — every method becomes a no-op, including after consent is granted. |
consentMode | No | basic | Controls whether markup() emits only the consent default, or the full head bootstrap so the Google tag loads before the visitor chooses. See Consent mode. |
waitForUpdate | No | - | Milliseconds GA4 holds events while waiting for a consent update, added to the consent default command emitted by markup(). See Consent defaults. |
nonce | No | - | CSP nonce added to the dynamically loaded Google tag script |
gtagName | No | gtag | Global Google tag function name |
dataLayerName | No | dataLayer | Global data layer name. |
gtagConfig | No | See below | Google tag configuration |
Multiple measurement IDs
Section titled “Multiple measurement IDs”Each ID gets its own gtag("config", id, gtagConfig) command, so a GA4 property and a
Google Ads tag can share one provider:
ga4Provider({ measurementIds: [ import.meta.env.PUBLIC_GA4_MEASUREMENT_ID, import.meta.env.PUBLIC_GOOGLE_ADS_ID, ],});The Google Ads target has to be configured for gtag("event", "conversion", { send_to: "AW-…/label" })
calls to be recorded, so include its ID here if the application sends Ads conversions.
One Google tag script serves every destination: it is loaded once, for the first ID, and the
remaining IDs are connected by their own config commands. Loading a second script per ID
would process the same data layer twice and duplicate events.
Script loading
Section titled “Script loading”The provider injects https://www.googletagmanager.com/gtag/js?id=<first measurement ID>&l=<dataLayerName>
during init, but only when the page has no Google tag script bound to the same data layer.
The check compares the script’s l query parameter (absent means dataLayer), not its id,
because the data layer is what decides whether commands are processed: gtag() pushes onto
window[dataLayerName], and only a script loaded with that same l reads from it. A tag
already present on the same data layer will therefore handle the provider’s config
commands whatever its own id is, and a tag on a different data layer will not — even if
its id matches.
An application that needs GA4’s consent-mode default to run before the tag loads — typically an inline script in the document head — can therefore install the tag itself without any extra provider configuration:
<script async src="https://www.googletagmanager.com/gtag/js?id=G-MEASUREMENT-ID"></script>Consent mode
Section titled “Consent mode”Google distinguishes basic and advanced consent mode. Basic mode prevents Google tags from loading until the visitor interacts with the consent banner, so nothing is sent beforehand — not even the consent state. Advanced mode loads the tag immediately with consent defaulted to denied and sends cookieless pings, which lets Google model conversions for visitors who decline instead of falling back to its general model.
consentMode: "basic" (the default) makes markup() emit only the data layer, the gtag shim and the consent default command. The tag script and its config commands run in init(), which the analytics service calls once the required consent is granted.
consentMode: "advanced" makes markup() emit the complete head bootstrap — data layer, shim, consent default, gtag("js", …), one config command per measurement ID, and the tag script-src. The application only renders the contributions, and needs no GA4 knowledge of its own:
---import { analytics } from "@/libs/analytics";
const markup = analytics.markup?.() ?? [];---
{ markup.map((contribution) => contribution.kind === "inline-script" ? ( <script is:inline id={contribution.id} set:html={contribution.content} /> ) : contribution.kind === "script-src" ? ( <script is:inline async src={contribution.src} /> ) : null, )}init() still runs the same commands when consent is granted, so in advanced mode gtag("js", …) and the config commands are issued twice — once by the markup and once by init(). A repeat config for an already-configured destination is a no-op while send_page_view is false, so the provider does not try to detect the markup: an extra config command is cheaper than the cross-context state needed to avoid it, and a forgotten markup() simply degrades to basic mode. The tag script is not loaded twice — init() skips it when a tag is already present on the same data layer.
Consent defaults
Section titled “Consent defaults”markup() returns an inline script that defines the data layer and sends
gtag("consent", "default", …) from the service’s default consent state. Render it in the
document head so it runs before the Google tag loads — GA4 applies whichever consent state
it sees first, so a tag that loads before the default command treats the visitor as fully
consented.
waitForUpdate adds GA4’s wait_for_update field, which tells GA4 to hold
events for that many milliseconds before deciding, giving an asynchronous consent banner
time to call analytics.updateConsent(). Without it, events fired in the first moments of a
page load are sent under the denied default even if the visitor has already consented.
ga4Provider({ measurementIds: [import.meta.env.PUBLIC_GA4_MEASUREMENT_ID], waitForUpdate: 500,});gtagConfig
Section titled “gtagConfig”The gtagConfig object is passed to the GA4 gtag("config", <measurement ID>, gtagConfig) command during provider initialization. The well-known fields are typed, and any other GA4 configuration field is accepted — for example linker for cross-domain measurement:
ga4Provider({ measurementIds: [import.meta.env.PUBLIC_GA4_MEASUREMENT_ID], gtagConfig: { debug_mode: import.meta.env.ENVIRONMENT === "development", send_page_view: false, linker: { domains: ["accounts.example.com"] }, },});| Option | Default | Description |
|---|---|---|
send_page_view | true | Controls whether GA4 sends a page_view event automatically when the provider runs the config command. |
debug_mode | - | Marks events for GA4 DebugView. The provider also enables this automatically when analytics service debug mode is enabled. |
The send_page_view option determines how page views are handled:
send_page_view: trueuses GA4 automatic page-view tracking. The custom GA4page()implementation does not send another event whenanalytics.page()is called.send_page_view: falsedisables the automatic page view sent by the GA4configcommand. The custom GA4page()implementation runs whenanalytics.page()is called and sends apage_viewevent withpage_title,page_location,page_path,page_hash,page_search, andpage_referrer.
Use send_page_view: false when the application controls navigation or needs to send page views manually, for example in a single-page application. Disabling the automatic page view avoids duplicate page_view events.
debug_mode has no default because GA4 only leaves debug mode off when the field is absent — sending debug_mode: false keeps it on. The provider deletes the field rather than sending false, so passing debug_mode: false explicitly is the same as omitting it.
Define GA4 event parameters under the ga4 provider key, then register ga4Provider() when creating the analytics service:
import { createAnalyticsService, createEvent, createEventDefinition, createEventRegistry, ga4Provider,} from "@releafuk/analytics";
const purchase = createEvent< { orderId: string; total: number; currency: string; items: Array<{ productId: string; name: string; }>; }, "purchase">({ name: "purchase",});
const events = createEventRegistry().register( createEventDefinition(purchase, { providers: { ga4: (data) => ({ event: "purchase", params: { items: data.items.map((item) => ({ item_id: item.productId, item_name: item.name, })), currency: data.currency, value: data.total, transaction_id: data.orderId, }, }), }, }),);
export const analytics = createAnalyticsService({ app: "releaf", events, providers: [ ga4Provider({ measurementIds: [import.meta.env.PUBLIC_GA4_MEASUREMENT_ID], }), ], consent: { default: { analytics: "denied", advertising: "denied", functionality: "denied", adUserData: "denied", adPersonalization: "denied", }, },});
await analytics.init();Render the provider markup in the document head so the default consent command runs before GA4 is initialized. For example, in an Astro layout:
---import { analytics } from "@/libs/analytics";---
<head> { analytics.markup?.().map((contribution) => { if (contribution.kind === "inline-script") { return ( <script is:inline id={contribution.id} set:html={contribution.content} transition:persist /> ); } }) }</head>