Embedding Google Maps GDPR-compliant — consent box with app links


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:

  1. Before the click: zero requests to Google. No map, no tile, nothing.
  2. Real consent — clicking “Show once” or “Agree and remember” is the consent (Art. 6(1)(a) GDPR).
  3. 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

PropTypeRequiredDescription
latnumber✓ (or src)Latitude
lngnumber✓ (or src)Longitude
titlestringPlace name (label + app links)
srcstring✓ (or lat+lng)Manually constructed embed URL
hrefstringOverrides the Google Maps link
zoomnumberZoom 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 of strict-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 from yt-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:

Naka Weekend Market, Phuket

Takeaway

Same architecture as the YouTube embed, three notable differences:

  1. No privacy mode — there is no maps-nocookie.com equivalent. The consent gate is the only safeguard before the embed loads.
  2. App links always visible — Google Maps and Apple Maps as a fallback, even without the embedded iframe.
  3. Coordinates-firstlat/lng props build everything automatically, no manual embed URL construction needed.