Skip to content

Google Analytics 4 (GA4)

Google Analytics 4 is used for web analytics and conversion tracking across web applications.

AppIntegrated
UK✅
DE✅

GA4 can be integrated via Google tag (gtag.js) API. An overview of the integration methods can be found via the links below:

An overview of the debugging tools can be found via the links below:

Before integrating GA4, note the following:

  • Default consent should be configured before GA4 loads.
  • Campaign attribution needs no code. gtag.js sends the full page URL as page_location, and GA4 parses utm_source, utm_medium, utm_campaign, utm_term, utm_content, utm_id, gclid and 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.

The @releafuk/analytics package provides a custom GA4 plugin through the ga4Provider() factory function. GA4 is integrated directly through gtag.js, without requiring GTM.

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;
};
OptionRequiredDefaultDescription
measurementIdsYes-One or more GA4 measurement IDs. See Multiple measurement IDs. An empty array makes the provider a no-op.
enabledNotrueControls 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.
consentModeNobasicControls whether markup() emits only the consent default, or the full head bootstrap so the Google tag loads before the visitor chooses. See Consent mode.
waitForUpdateNo-Milliseconds GA4 holds events while waiting for a consent update, added to the consent default command emitted by markup(). See Consent defaults.
nonceNo-CSP nonce added to the dynamically loaded Google tag script
gtagNameNogtagGlobal Google tag function name
dataLayerNameNodataLayerGlobal data layer name.
gtagConfigNoSee belowGoogle tag configuration

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.

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>

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.

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,
});

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"] },
},
});
OptionDefaultDescription
send_page_viewtrueControls 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: true uses GA4 automatic page-view tracking. The custom GA4 page() implementation does not send another event when analytics.page() is called.
  • send_page_view: false disables the automatic page view sent by the GA4 config command. The custom GA4 page() implementation runs when analytics.page() is called and sends a page_view event with page_title, page_location, page_path, page_hash, page_search, and page_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>