Embedding Google Maps GDPR-compliant — consent box with app links
A lightweight Google Maps embed component for Astro: no request before consent, localStorage-based consent, automatic Apple Maps and Google Maps app links from GPS coordinates.
Same starting point as the YouTube embed: a standard <iframe src="google.com/maps/embed/…"> sends data to Google the moment the page loads — IP address, browser fingerprint, cookies — without the user having consented. For a publicly accessible website, that’s a GDPR problem.
What I needed:
- Before the click: zero requests to Google. No map, no tile, nothing.
- Real consent — clicking “Show once” or “Agree and remember” is the consent (Art. 6(1)(a) GDPR).
- App links — anyone who’d rather open the location directly in Google Maps or Apple Maps can do so without the iframe.
The component
src/components/GoogleMapsEmbed.astro — usage as simple as possible:
<GoogleMapsEmbed
lat={7.8807048}
lng={98.3657287}
title="Naka Weekend Market, Phuket"
/>
That’s it. src, href, and zoom (default: 15) are optional — the component builds the embed URL and all app links automatically from the coordinates.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
lat | number | ✓ (or src) | Latitude |
lng | number | ✓ (or src) | Longitude |
title | string | — | Place name (label + app links) |
src | string | ✓ (or lat+lng) | Manually constructed embed URL |
href | string | — | Overrides the Google Maps link |
zoom | number | — | Zoom level (default: 15) |
Core template
<figure class="maps-embed">
{title && <figcaption class="maps-caption">{title}</figcaption>}
<div class="maps-consent" data-src={src} data-title={title ?? ''}>
<h3>{t(locale, 'maps.externalContent')}</h3>
<p>{t(locale, 'maps.description')}</p>
<p>{t(locale, 'maps.serviceName')}</p>
<div class="maps-consent-buttons">
<button data-remember="true">{t(locale, 'maps.loadMapRemember')}</button>
<button>{t(locale, 'maps.loadMap')}</button>
</div>
<p class="maps-consent-footer">
{t(locale, 'maps.consentFooter')}
<a href={privacyHref}>{t(locale, 'maps.privacyLink')}</a>.
</p>
</div>
<div class="maps-actions">
<a href={googleMapsHref}>📍 Google Maps</a>
{appleMapsHref && <a href={appleMapsHref}>📍 Apple Maps</a>}
</div>
</figure>
The maps-actions bar with the app links sits outside .maps-consent — it’s always visible, whether the map has been loaded or not.
Script
<script>
const CONSENT_KEY = 'gmaps-consent';
function buildIframe(src, title) {
const iframe = document.createElement('iframe');
iframe.src = src;
iframe.allow = 'fullscreen';
iframe.loading = 'lazy';
iframe.referrerPolicy = 'no-referrer-when-downgrade';
iframe.style.cssText = 'width:100%;aspect-ratio:4/3;border:0;display:block';
if (title) iframe.title = title;
return iframe;
}
const hasConsent = (() => {
try { return localStorage.getItem(CONSENT_KEY) === '1'; } catch { return false; }
})();
document.querySelectorAll('.maps-consent').forEach((container) => {
const { src, title } = container.dataset;
if (!src) return;
if (hasConsent) { container.replaceWith(buildIframe(src, title)); return; }
container.querySelectorAll('button').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.dataset.remember === 'true') {
try { localStorage.setItem(CONSENT_KEY, '1'); } catch {}
}
container.replaceWith(buildIframe(src, title));
});
});
});
</script>
Two differences from the YouTube script:
referrerPolicy: 'no-referrer-when-downgrade'instead ofstrict-origin-when-cross-origin— Google Maps needs the full referrer (protocol + domain) but doesn’t fail on missing paths the way YouTube does.- Separate
CONSENT_KEY(gmaps-consent) — distinct fromyt-consent, because these are different services. Consenting to YouTube does not imply consent to Maps.
Coordinate logic
// Auto-build src from coordinates if not provided
if (!src && lat != null && lng != null) {
src = `https://maps.google.com/maps?q=${lat},${lng}&output=embed&zoom=${zoom}`;
}
// Apple Maps URL
const appleMapsHref = lat && lng
? `https://maps.apple.com/?ll=${lat},${lng}&q=${encodeURIComponent(title ?? '')}`
: title
? `https://maps.apple.com/?q=${encodeURIComponent(title)}`
: null;
If no explicit lat/lng props are passed, the component parses the coordinates from the src URL (format ?q=lat,lng).
i18n
New keys in src/i18n/ui.ts:
'maps.externalContent': 'External content',
'maps.description': 'This is where you\'ll find an interactive Google Maps map.',
'maps.serviceName': 'Google Maps (maps.google.com)',
'maps.loadMap': 'Show once',
'maps.loadMapRemember': 'Agree and remember',
'maps.openInMaps': 'Open in Google Maps',
'maps.consentFooter': 'I agree to external content being displayed…',
'maps.privacyLink': 'privacy policy',
Adjusting the CSP
Google Maps needs two additional entries in frame-src:
- frame-src https://www.youtube-nocookie.com;
+ frame-src https://www.youtube-nocookie.com https://maps.google.com https://www.google.com;
The key difference from YouTube: Google Maps has no “privacy-enhanced mode” equivalent to youtube-nocookie.com. The consent gate is the only protection before page load.
Demo
Naka Weekend Market in Phuket:
Takeaway
Same architecture as the YouTube embed, three notable differences:
- No privacy mode — there is no
maps-nocookie.comequivalent. The consent gate is the only safeguard before the embed loads. - App links always visible — Google Maps and Apple Maps as a fallback, even without the embedded iframe.
- Coordinates-first —
lat/lngprops build everything automatically, no manual embed URL construction needed.