Navigation Control
Navigation control extends airgap.js' existing consent enforcement, override, and telemetry capabilities to top-level page navigations. When navigation control is active, top-level navigations, including most link clicks, form submissions, location assignments, history navigations, and other navigational transitions that changes the current document are intercepted by airgap.js to apply network consent rules. Pending navigation events can be inspected, rewritten, allowed, or blocked before proceeding.
This capability lets you treat outbound navigations as first-class regulated requests: gate them on consent, rewrite their URLs (for example, to strip tracking parameters or inject consent state for downstream domains), and surface them in airgap.js telemetry alongside the rest of your traffic.
Top-level navigations are one of the largest blind spots in a typical consent and privacy program. A user can click a link that carries gclid, fbclid, utm_*, or other identifiers to a third-party destination or to another property you own, bypassing all of your normal request-time controls. Once the browser commits the navigation, the current document is gone and you've lost your chance to intervene.
Navigation control closes that gap. It gives you a single, declarative place to decide what navigations are allowed, what they should look like on the wire, and how they're reported — using the same overrides, consent, and telemetry primitives used for other request types.
The most common reasons to enable navigation control are:
Stripping non-allowlisted query parameters from navigations. This is the most popular use case. Many sites want to ensure that tracking identifiers (gclid, fbclid, utm_*, mc_eid, click IDs from affiliate networks, etc.) are removed from outbound URLs — either across the board, on cross-origin navigations only, or for specific destinations. Navigation control lets you rewrite URL.search (or selectively delete individual searchParams) before the browser commits the navigation.
Propagating consent across a known set of first-party domains. Sites that span multiple eTLD+1 domains (for example, a retailer and its travel arm, or a corporate site and a separate help center) often want a user's consent decision on one property to follow them to the others without re-prompting. Navigation control lets you inject a signed or serialized consent payload into cross-site navigations to a well-known allowlist of partner domains, and a small bootstrap script on each receiving domain can read that payload back out of location.search and seed its local consent state.
Blocking or rewriting navigations to disallowed destinations. You can use the same hook to redirect users away from URLs that fail a policy check, upgrade http:// links to https://, or rewrite legacy domains to their canonical replacements.
Reporting navigation domains in telemetry. Because navigations flow through the same telemetry pipeline as other requests, you get increased visibility into where users are going to from your site — which is useful for auditing, debugging, and verifying that other parts of your privacy program are working. Conversely, if you don't want navigations cluttering telemetry but otherwise need navigation control active, you can suppress them with a suppressTelemetry.requestConditions entry; e.g.
// stub airgap.js init config surface
const { suppressTelemetry } = (self.airgap = {
suppressTelemetry: {},
...self.airgap
});
// register telemetry suppression request condition
(suppressTelemetry.requestConditions = suppressTelemetry.requestConditions || [])
.push(({ type }) =>
type === 'navigation'
)Navigation control is opt-in. It can be activated by setting data-regulate-navigation="on" on your airgap.js script tag or by setting airgap.loadOptions.regulateNavigation = 'on' pre-init. Once enabled, top-level navigation events flow through the same overrides that airgap.js already uses for other request types.
If you wish to include detected top-level navigations in telemetry without applying regulations, set data-regulate-navigation="report-only" on your airgap.js script tag or by setting airgap.loadOptions.regulateNavigation = 'report-only' pre-init. Consent enforcement and overrides are not applied in this mode.
Navigation control cannot intercept some navigations initiated by browser UI, such as entering a new location in the address bar or using the browser's back-forwards buttons.
Consent enforcement and overrides for most navigations requires support for the Navigation API. In situations where the Navigation API is not supported, navigation control enforcement and telemetry is limited to form submissions and the open() API.
To handle navigations specifically in request overrides, check for event.type === 'navigation'. Changes can be applied by writing to event.urls or by calling event.{allow, deny, block}().
A minimal override that strips all query parameters from cross-origin navigations looks like this:
(self.airgap = {
overrides: [],
...self.airgap
}).overrides.push({
override(event) {
const { URLs, urls, type } = event;
if (type === 'navigation') {
// allow all navigations
event.allow();
// remove query params from cross-origin navigations
URLs.forEach((URL, i) => {
if (URL.origin !== origin) {
URL.search = '';
urls[i] = URL.href;
}
});
}
}
})This snippet keeps same-origin navigations untouched and clears the entire query string for cross-origin navigations.
The following example script intended to run prior to loading airgap.js shows a production-shaped use of navigation control. It does four things together: enables navigation control, suppresses navigations from telemetry, seeds consent into outbound first-party cross-site navigations to a known allowlist, and reads any seeded consent back out of the URL on arrival so the destination domain picks up the consent state without prompting the user again.
(() => {
// domains to automatically propagate consent across
const SEED_CONSENT_ACROSS = ['example.com', 'example.net', 'example.org'];
// if false, allow navigations lacking a referrer to receive URL-seeded consent
const REQUIRE_AUTHORIZED_REFERRER = true;
const shouldSeedConsentAcross = ({ hostname }) =>
SEED_CONSENT_ACROSS.some((seedHost) => hostname === seedHost || hostname.endsWith(`.${seedHost}`));
const view = typeof globalThis !== 'undefined' ? globalThis : self;
const { location, origin, localStorage, cookieStore, history, document } = view;
if (!(location && localStorage && cookieStore && history && document)) return;
const CONSENT_PARAM = 'tcm-consent';
// stub airgap.js API
const airgapStub = (view.airgap = {
readyQueue: [],
ready(callback) {
this.readyQueue.push(callback);
},
overrides: [],
suppressTelemetry: {},
loadOptions: {},
...view.airgap
});
// receive seeded consent
const { partition } = airgapStub.loadOptions;
const { referrer } = document;
// limit receiving consent to expected sites if referrer exists
if (
(!REQUIRE_AUTHORIZED_REFERRER && !referrer) ||
(referrer && shouldSeedConsentAcross(new URL(referrer)))
) {
const url = new URL(location);
const hashAsSearchParams = new URLSearchParams(url.hash.slice(1));
const seededConsent = hashAsSearchParams.get(CONSENT_PARAM);
if (seededConsent) {
hashAsSearchParams.delete(CONSENT_PARAM);
url.hash = `${hashAsSearchParams}`;
history.replaceState(null, null, url.href);
const { tcmMPConsent } = localStorage;
if (partition) {
const parseConsentStoreJSON = (json) => {
try {
return JSON.parse(json);
} catch {
return {};
}
}
const consentStore = tcmMPConsent ? parseConsentStoreJSON(tcmMPConsent) : {};
consentStore[partition] = seededConsent;
localStorage.tcmMPConsent = JSON.stringify(consentStore);
} else {
localStorage.tcmConsent = seededConsent;
}
cookieStore.set(`tcm${partition ? `-${partition}` : ''}`, seededConsent);
}
}
// activate navigation control
airgapStub.loadOptions.regulateNavigation = 'on';
// suppress navigations in telemetry
(airgapStub.suppressTelemetry = {
requestConditions: [],
...airgapStub.suppressTelemetry
}).requestConditions.push((event) => event.type === 'navigation');
let airgap;
airgapStub.ready((api) => {
airgap = api;
});
airgapStub.overrides.push({
override: (event) => {
if (event.type === 'navigation') {
// allow all navigations
event.allow();
// seed consent in first-party cross-site navigations
event.URLs.forEach((url, i) => {
if (url.origin !== origin && shouldSeedConsentAcross(url)) {
const hashAsSearchParams = new URLSearchParams(url.hash.slice(1));
hashAsSearchParams.set(CONSENT_PARAM, JSON.stringify(airgap.getConsent()));
url.hash = `${hashAsSearchParams}`;
event.urls[i] = url.href;
}
});
}
}
});
})();To use this pattern on your own properties, configure the seedConsentAcross array and insert this code before airgap.js on the configured domains.
The seedConsentAcross array should list the hostnames you want to share consent across. These hostnames can match subdomains, so listing example.com will also match www.example.com and shop.example.com.