# How to Add Geofencing Notifications to Your App — Copy-Paste Prompts Inside

- Tool: App Features
- Last updated: July 2026

## TL;DR

Geofencing notifications need four pieces: browser Geolocation API to watch the user's position, a Haversine or turf.js calculation to check whether that position crosses a zone boundary, a state machine to fire the notification only once per zone entry (not on every position update), and a Supabase table with PostGIS for server-side validation of high-stakes triggers. With Lovable or V0 you can ship a working web geofencing feature in 6-10 hours. Running cost is $0-25/month at 100 users, rising to $75-150/month at 10,000 users as Mapbox map loads become the main variable.

## What Web Geofencing Notifications Actually Are

Geofencing notifications fire when a user's device crosses into or out of a defined geographic zone. On the web, this means the browser Geolocation API continuously watches the user's position using watchPosition(), and a client-side function evaluates whether the current coordinates fall inside any active geofence radius. When the user transitions from outside to inside a zone (or vice versa), a Web Notification fires and a geofence_events row is logged in Supabase. The critical architectural constraint for web geofencing is that it is entirely foreground-only — the browser cannot detect location when the tab is closed or the device screen is off. True background geofencing requires a native mobile app (Flutter with the geolocator package). The product decisions that matter are: who defines zones (admins draw them on a map), how large they are (100m radius works reliably, 10m does not), and whether zone entry should trigger server-side actions (unlock a discount, log a compliance check-in) in addition to a client-side notification.

## Anatomy of the Feature

Seven components. The map UI and Supabase tables are what AI tools generate correctly. The zone membership state machine, watchPosition cleanup, and PostGIS coordinate-axis ordering are the components where builds break.

- **Geolocation Watcher** (service): navigator.geolocation.watchPosition(successCallback, errorCallback, { enableHighAccuracy: false, maximumAge: 30000, timeout: 5000 }) starts tracking when the user clicks 'Enable location alerts'. The maximumAge: 30000 setting allows the browser to cache positions for up to 30 seconds, reducing battery impact. enableHighAccuracy: false avoids GPS radio activation (uses WiFi/cell triangulation) which is sufficient for zone radii above 50 meters.
- **Zone Definition UI** (ui): Mapbox GL JS or Leaflet.js map with circle drawing capability. Admins click a point on the map to set the zone center and drag a radius slider to configure the radius in meters. The zone center (center_lat, center_lng) and radius_meters are saved to the geofences Supabase table on form submit. Mapbox GL JS renders existing zones as circle layers using the addSource / addLayer API inside the map.on('load') callback.
- **Geofences Table** (data): Supabase PostgreSQL table with PostGIS extension enabled: geofences (id uuid, name text, center_lat numeric, center_lng numeric, radius_meters int, trigger_on text CHECK trigger_on IN ('enter','exit','both'), is_active bool, metadata jsonb, created_at timestamptz). The metadata jsonb field stores zone-specific CTA data (offer text, deep link, compliance action type). PostGIS is enabled via CREATE EXTENSION IF NOT EXISTS postgis in the Supabase SQL editor.
- **Client-Side Zone Checker** (service): JavaScript Haversine formula or turf.js booleanPointInPolygon() / distance() evaluates the current watchPosition coordinates against all active geofences loaded into memory. Zone membership state is tracked in a JavaScript Map keyed by geofence ID: { [geofenceId]: 'inside' | 'outside' }. An enter notification fires only when the state transitions from 'outside' to 'inside'. An exit notification fires only on 'inside' to 'outside'. The geofence list is fetched once on initialization and refreshed every 5 minutes.
- **Zone Event Logger** (backend): Supabase insert via the client-side SDK records geofence_events (user_id, geofence_id, event_type 'enter'|'exit', lat, lng, occurred_at) when a zone transition fires. Deduplication is enforced at the application layer: only log if the previous event for the same user+geofence pair was of a different type (no consecutive 'enter' events). A UNIQUE constraint on (user_id, geofence_id, event_type, date_trunc('minute', occurred_at)) provides a database-level safety net.
- **Notification Trigger** (service): On zone enter or exit: if Notification.permission === 'granted', calls new Notification(zoneName, { body: notificationBody, icon: '/icon.png', data: { geofenceId, ctaUrl } }) to show a Web Notification. Falls back to an in-app toast (sonner or shadcn/ui Toast) if permission is denied or if the user has not opted in to browser notifications. The notification body and CTA link are drawn from the geofence's metadata.cta_text and metadata.deep_link fields.
- **PostGIS Server-Side Validation** (backend): Supabase Edge Function for server-side zone membership verification using ST_DWithin: SELECT id, name FROM geofences WHERE is_active = true AND ST_DWithin(ST_MakePoint(lng, lat)::geography, ST_MakePoint(center_lng, center_lat)::geography, radius_meters). Called for high-stakes triggers like unlocking discounts or logging compliance check-ins where client-side spoofing must be prevented. Returns the list of geofences containing the submitted coordinates.

## Data model

Run this in the Supabase SQL editor. Enables PostGIS, creates the geofences and event log tables with RLS, and adds the spatial index for fast zone lookup. Enabling PostGIS is the first step — do it before creating the geofences table.

```sql
-- Step 1: Enable PostGIS extension (run this first)
create extension if not exists postgis;

-- Geofences table: zones defined by admin users
create table public.geofences (
  id uuid default gen_random_uuid() primary key,
  name text not null,
  center_lat numeric not null,
  center_lng numeric not null,
  radius_meters int default 100,
  trigger_on text default 'enter' check (trigger_on in ('enter','exit','both')),
  is_active bool default true,
  metadata jsonb, -- { cta_text, deep_link, compliance_action, offer_code }
  created_at timestamptz default now()
);

alter table public.geofences enable row level security;

-- Authenticated users can read active geofences (to load into client-side checker)
create policy "Authenticated users can read active geofences"
  on public.geofences for select
  to authenticated
  using (is_active = true);

-- Only service role (admins) can create/update/delete geofences
create policy "Service role manages geofences"
  on public.geofences for all
  to service_role using (true);

-- Spatial index for fast ST_DWithin queries
create index geofences_location_idx
  on public.geofences
  using gist (st_makepoint(center_lng, center_lat)::geography);

-- Geofence events: zone entry and exit log
create table public.geofence_events (
  id uuid default gen_random_uuid() primary key,
  user_id uuid references auth.users,
  geofence_id uuid references public.geofences(id) on delete cascade,
  event_type text check (event_type in ('enter','exit')),
  lat numeric,
  lng numeric,
  occurred_at timestamptz default now()
);

alter table public.geofence_events enable row level security;

-- Users can insert their own events and read their own history
create policy "Users can insert own geofence events"
  on public.geofence_events for insert
  with check (auth.uid() = user_id);

create policy "Users can read own geofence events"
  on public.geofence_events for select
  using (auth.uid() = user_id);

-- Admins (service_role) can read all events for the dashboard
create policy "Service role can read all events"
  on public.geofence_events for select
  to service_role using (true);

create index geofence_events_user_time_idx
  on public.geofence_events (user_id, occurred_at desc);

-- PostGIS server-side validation function
-- Call this from an Edge Function with the user's lat/lng
create or replace function public.check_geofence_membership(
  user_lat numeric,
  user_lng numeric
) returns table (geofence_id uuid, geofence_name text, trigger_on text, metadata jsonb)
language sql
security definer
set search_path = public
as $$
  select id, name, trigger_on, metadata
  from geofences
  where is_active = true
    and st_dwithin(
      st_makepoint(user_lng, user_lat)::geography,
      st_makepoint(center_lng, center_lat)::geography,
      radius_meters
    );
$$;
```

The spatial index on geofences uses a GiST index type required by PostGIS geography operations. The check_geofence_membership function uses SECURITY DEFINER so it can be called from client-side code via Supabase RPC without granting direct table access to the geography functions.

## Build paths

### Lovable — fit 2/10, 8-10 hours

Lovable can scaffold the Supabase tables and basic Mapbox UI, but the Geolocation API, watchPosition lifecycle management, zone state machine, and PostGIS integration are non-trivial to wire correctly via AI prompting in a single pass. Use Plan Mode first.

1. Switch to Plan Mode and describe the full architecture: Geolocation watcher, client-side zone checker with membership state map, Web Notifications API, Supabase tables with PostGIS, and the Mapbox GL JS admin map; generate a plan before writing code
2. Open the Supabase Dashboard, go to Database → Extensions, enable the 'postgis' extension manually — Lovable cannot enable it automatically
3. Add your MAPBOX_ACCESS_TOKEN in the Lovable Cloud tab under Secrets; Mapbox GL JS requires this token to load tiles
4. Paste the prompt below in Agent Mode; implement in two phases — tables and zone checker first, then map UI and notifications second
5. Publish the app to HTTPS before testing any location or notification functionality — both APIs are blocked in the Lovable preview iframe

Starter prompt:

```
Build a web geofencing notification feature. Use Mapbox GL JS for maps and the browser Geolocation API for location tracking. Data layer: create two Supabase tables. Table 1: geofences (id uuid primary key default gen_random_uuid(), name text not null, center_lat numeric, center_lng numeric, radius_meters int default 100, trigger_on text check (trigger_on in ('enter','exit','both')) default 'enter', is_active bool default true, metadata jsonb, created_at timestamptz default now()); RLS: authenticated users SELECT where is_active = true; service_role full access. Table 2: geofence_events (id uuid primary key default gen_random_uuid(), user_id uuid references auth.users, geofence_id uuid references geofences(id) on delete cascade, event_type text check (event_type in ('enter','exit')), lat numeric, lng numeric, occurred_at timestamptz default now()); RLS: users INSERT own rows, SELECT own rows. Run CREATE EXTENSION IF NOT EXISTS postgis in the SQL editor before creating tables. Location tracker component: a React client component with an 'Enable location alerts' button that calls Notification.requestPermission() first (show an explanation modal before the system dialog), then starts navigator.geolocation.watchPosition({ enableHighAccuracy: false, maximumAge: 30000, timeout: 5000 }). On each position update: load all active geofences from Supabase once on init and refresh every 5 minutes. For each geofence, compute distance from current position using the Haversine formula (do not use an external library for this): distance = 2 * 6371000 * asin(sqrt(sin((lat2-lat1)/2*PI/180)^2 + cos(lat1*PI/180)*cos(lat2*PI/180)*sin((lng2-lng1)/2*PI/180)^2)). Track zone membership in a JavaScript Map: { [geofenceId]: 'inside' | 'outside' }, initialized to 'outside' for all zones. Fire enter notification only when transitioning 'outside' -> 'inside'; fire exit only on 'inside' -> 'outside'. Use new Notification(geofence.name, { body: geofence.metadata.cta_text, icon: '/icon.png' }) if permission granted; fall back to a sonner toast if not. On zone enter/exit, insert a row into geofence_events via Supabase insert. Clean up watchPosition on component unmount via clearWatch. Edge case: if user denies geolocation permission, show an in-app card explaining what geofencing is for, with browser-specific instructions for re-enabling location in Chrome (Settings > Privacy > Site Settings > Location) and Safari (Settings > Safari > Location). Admin zone management page: show a Mapbox GL JS map initialized with the MAPBOX_ACCESS_TOKEN environment variable. Load all geofences from Supabase and render each as a circle layer in the map.on('load', () => { ... }) callback — never before the load event. Add a form overlay to create a new geofence: name text input, click-to-select center on map, radius slider 50-2000 meters, trigger_on selector, cta_text and deep_link fields in metadata. Save to Supabase on submit.
```

Limitations:

- The Lovable preview iframe blocks the Geolocation API (requires HTTPS and a non-sandboxed context) — test all location and notification features on the published URL only
- PostGIS must be enabled manually in the Supabase Dashboard before running the table creation SQL — Lovable cannot enable extensions automatically
- Lovable may use setInterval polling instead of watchPosition on first pass; inspect the generated code and specify watchPosition explicitly in a follow-up prompt if needed

### V0 — fit 3/10, 6-9 hours

V0 generates Next.js with Mapbox GL JS for zone visualization and the Geolocation API watcher in a client component. Good UI for admin zone management. Supabase PostGIS for server-side validation needs explicit prompting.

1. Prompt V0 with the spec below to generate the geofencing client component, admin map page, and Supabase integration
2. Add environment variables in the V0 Vars panel: NEXT_PUBLIC_MAPBOX_TOKEN, NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
3. Run the SQL schema from this page in the Supabase SQL editor, starting with CREATE EXTENSION IF NOT EXISTS postgis
4. Deploy to Vercel and test the geolocation and notification flow from the live HTTPS URL on a real device — the V0 sandbox cannot access device location
5. Verify the Mapbox circle layers by creating a test geofence and checking that it renders correctly on the admin map before enabling live location tracking

Starter prompt:

```
Build a Next.js geofencing notification feature with Mapbox GL JS and Supabase. Create two pages: /geo-alerts (user-facing location tracker) and /admin/geofences (admin zone management map). Page 1 — /geo-alerts: a 'use client' component with an 'Enable location alerts' button. On click: show a permission explanation modal ('We use your location to notify you when you are near relevant locations. Your location is not stored continuously.'), then call Notification.requestPermission(). If granted, start navigator.geolocation.watchPosition(onSuccess, onError, { enableHighAccuracy: false, maximumAge: 30000, timeout: 5000 }). Store the watchId in a useRef and call navigator.geolocation.clearWatch(watchIdRef.current) in the useEffect cleanup to prevent memory leaks. On each position update: load active geofences from Supabase (SELECT * FROM geofences WHERE is_active = true) once on init and re-fetch every 5 minutes via setInterval. Store geofences in a useRef to avoid triggering re-renders. Track zone membership in a useRef Map { [id]: 'inside'|'outside' }. For each geofence, compute distance using Haversine formula. If distance < radius_meters and previous state was 'outside': update state to 'inside', show a Web Notification (geofence.name, body: geofence.metadata?.cta_text), insert into geofence_events ({user_id, geofence_id, event_type: 'enter', lat, lng}). If distance > radius_meters and previous state was 'inside': update state to 'outside', trigger exit notification if geofence.trigger_on is 'exit' or 'both', insert exit event. Handle NotAllowedError by showing a shadcn/ui Alert with instructions: 'To enable location alerts in Chrome: click the lock icon in the address bar, then set Location to Allow.' Handle NotAvailableError: 'Location is unavailable. Please check your device location settings.' Page 2 — /admin/geofences: load Mapbox GL JS via dynamic import (to avoid SSR errors). Initialize map in a useEffect with NEXT_PUBLIC_MAPBOX_TOKEN. In map.on('load') callback: fetch all geofences from Supabase using the service role key from a Next.js API route; for each, addSource with type 'geojson' and addLayer type 'fill' with circle-radius calculated from radius_meters at the map's current zoom level. Add a sidebar form to create a new geofence: name input, address search input (call Mapbox Geocoding API to convert to lat/lng), radius slider 50-2000 meters, trigger_on select (enter/exit/both), cta_text input. On save: insert to geofences table, reload layers. Also create a Next.js API route at /api/validate-geofence (POST, body: {lat, lng}) that uses the Supabase service role client to call the check_geofence_membership SQL function via RPC and returns matching geofences — use this for server-side validation of high-stakes triggers.
```

Limitations:

- V0's Next.js App Router requires careful placement of the Geolocation watcher inside a 'use client' component — V0 sometimes generates the watcher in a Server Component which causes a runtime error on the Geolocation API access
- V0 may not include the PostGIS ST_DWithin validation in generated SQL; the server-side validation API route must be explicitly requested in the prompt
- Mapbox GL JS must be imported dynamically (next/dynamic with ssr: false) to avoid Next.js SSR errors — V0 sometimes omits this

### Custom — fit 5/10, 2-4 weeks

Full geofencing stack: PostGIS spatial indexes, native mobile background geofencing via Flutter geolocator package, Mapbox GL JS with custom polygon drawing, and a multi-zone analytics dashboard. Required for background detection or high-accuracy use cases.

1. PostGIS with spatial indexes (GiST on geography column) for fast server-side zone lookup across thousands of geofences without full table scan
2. Native mobile background geofencing via Flutter geolocator package with CLRegion (iOS) and Android geofencing API — these fire zone events even when the app is not in the foreground or the device screen is off
3. Mapbox GL JS with polygon drawing tools (Mapbox Draw plugin) for irregular shape zones beyond simple circles — retail floor plans, campus boundaries, hospital wings
4. Zone analytics dashboard: heat map of entry/exit events per zone per hour, user dwell time per zone, conversion events linked to zone entries
5. Server-side webhook trigger on zone entry via Supabase Realtime: geofence_events INSERT triggers a webhook to an external CRM, access control system, or logistics platform

Limitations:

- Native background geofencing on iOS via CLRegion is limited to 20 monitored regions per app — apps monitoring more regions need a server-side zone clustering strategy
- Sub-meter accuracy for construction, healthcare, or access control use cases is beyond what browser or standard Flutter GPS provides — requires dedicated hardware (UWB beacons, BLE positioning)

## Gotchas

- **Geolocation API returns permission denied even though user clicked Allow** — The browser Geolocation API only operates in secure contexts (HTTPS) and is blocked entirely inside sandboxed iframes. Both Lovable's preview pane and V0's sandbox run the app in an embedded iframe with a different origin from the published URL. When navigator.geolocation.getCurrentPosition() or watchPosition() is called in this context, the browser silently denies it and calls the error callback with code 1 (PERMISSION_DENIED), even if the user explicitly clicks Allow in the system dialog — because the permission applies to the iframe origin, not the published domain. Fix: Publish the app to HTTPS and test all geolocation and notification functionality from the live URL on a real device. Add feature detection: if (!('geolocation' in navigator)) show a clear message. Never interpret PERMISSION_DENIED in preview as a code bug — it is an expected browser security restriction.
- **Enter notification fires dozens of times while user stands inside a zone** — watchPosition fires a callback on every significant position update from the GPS or WiFi positioning system, which can happen every 2-10 seconds. Without a zone membership state machine, each position update that falls inside a geofence triggers a new notification. For a user standing in one location for 5 minutes, this produces 30-150 duplicate enter notifications — effectively spamming the user and burning through any rate limit on the notification API. Fix: Maintain a JavaScript Map (zoneStates) keyed by geofence ID with values 'inside' or 'outside'. On each position update, compute the zone state for every geofence. Only fire the enter notification when the state transitions from 'outside' to 'inside'. Only fire the exit notification when transitioning from 'inside' to 'outside'. Initialize all zones as 'outside' on load.
- **Zone circles don't appear on the Mapbox admin map** — Mapbox GL JS requires that addSource() and addLayer() calls are made after the map style has fully loaded. In React, the map object is available as soon as the Map constructor runs inside useEffect, but the style — which determines what layers can be added — finishes loading asynchronously. If addSource/addLayer are called at Map constructor time or at component mount time, the style is not ready and the calls fail silently. The map renders tile imagery but no geofence circles appear. Fix: Wrap all addSource and addLayer calls inside the map.on('load', () => { addGeofenceLayers() }) callback. When new geofences are created or the geofence list is refreshed, check if the style is already loaded using map.isStyleLoaded() before attempting to add layers, and call map.on('load', callback) otherwise.
- **PostGIS ST_DWithin returns no results even for coordinates inside the radius** — ST_MakePoint takes arguments in (longitude, latitude) order — x-axis (east-west) first, y-axis (north-south) second. This is the opposite of the conventional (latitude, longitude) order used in the Geolocation API result (coords.latitude, coords.longitude), most mapping documentation, and everyday language. When the arguments are passed in the wrong order, the ST_DWithin calculation computes the distance between two geographically nonsensical points and returns no matches. Fix: Always use ST_MakePoint(longitude, latitude) — not ST_MakePoint(latitude, longitude). Validate by running the function against a known coordinate pair: pick a location you know is inside a zone, run the query manually in the Supabase SQL editor, and verify it returns that zone. Add a comment in the SQL to document the argument order to prevent future confusion.

## Best practices

- Never request geolocation permission automatically on page load — request it after the user clicks an explicit 'Enable location alerts' button and show an in-app explanation modal before the browser's system dialog appears
- Use enableHighAccuracy: false in watchPosition options — for zone radii above 50 meters, WiFi/cell triangulation is sufficient and does not activate the GPS radio, significantly reducing battery impact
- Always clean up the watchPosition watchId in useEffect's cleanup function — a forgotten watchId keeps the browser location indicator active, confuses users, and drains battery
- Wrap all Mapbox addSource and addLayer calls inside the map.on('load') event — this is the single most common source of invisible geofence circles in new builds
- Always pass ST_MakePoint(longitude, latitude) — never (latitude, longitude) — and validate against a known coordinate pair before deploying the PostGIS validation function
- Cache the geofence list in a useRef (not useState) after the initial fetch to avoid re-renders triggering re-fetches on every position update; refresh the list every 5 minutes via a separate interval
- For map load cost optimization at scale, load the geofence list via a lightweight JSON endpoint instead of rendering a full Mapbox map on every page visit — only show the map on the admin zone management page

## Frequently asked questions

### Does geofencing work when the browser tab is closed?

No. Web browser geofencing using the Geolocation API is entirely foreground-only — the API stops firing position updates as soon as the tab loses focus or the screen locks. If your use case requires notifications when the device is away from the app, you need a native mobile app built with Flutter using the geolocator package, which uses iOS CLRegion and Android Geofencing API to detect zone crossings in the background.

### How accurate is browser geolocation for geofencing?

Typical accuracy with enableHighAccuracy: false (WiFi and cell triangulation) is 10-50 meters in urban areas with good signal. With enableHighAccuracy: true (GPS radio), accuracy improves to 3-10 meters outdoors but degrades significantly indoors and drains battery faster. For zone radii under 50 meters, GPS accuracy is required and even then indoor detection is unreliable. Design your zones with radii of at least 100 meters for reliable detection in all conditions.

### Can I draw an irregular polygon zone or only circles?

Both are possible. Circle geofences (center_lat, center_lng, radius_meters) are simpler to implement and cover the majority of use cases — a store's entrance area, a campus building, a neighborhood. Polygon geofences require storing coordinate arrays (PostGIS POLYGON geometry type) and using turf.js booleanPointInPolygon() for client-side checking instead of the Haversine formula. For a first build, start with circles; add polygon support as a follow-on feature once the circle pipeline is working.

### What is the smallest geofence radius I can reliably detect?

100 meters is the practical minimum for reliable detection in a web app using browser geolocation. Below 100 meters, GPS drift and positioning jitter (±10-30 meters) cause false enter and exit events as the device appears to move in and out of the zone while the user is standing still. If you need smaller zones — a specific door, a retail display, a hospital room — you need BLE beacons or UWB hardware, not browser geolocation.

### Will geofencing drain the user's phone battery?

Using enableHighAccuracy: false and maximumAge: 30000 in watchPosition significantly reduces battery impact — the browser uses WiFi and cell positioning rather than the GPS radio. On Chrome on Android, watchPosition in this mode consumes roughly the same battery as having a background tab open. For comparison, a native app using Android's Geofencing API or iOS CLRegion monitoring uses far less battery because the OS handles zone checking at the hardware level rather than a browser tab.

### How do I prevent the same notification from firing repeatedly inside a zone?

Track zone membership state in a JavaScript Map keyed by geofence ID. Initialize all zones as 'outside'. On each position update, if a zone's computed distance is less than its radius and the current state is 'outside', transition to 'inside' and fire the notification. Only fire again when the user exits and re-enters the zone. This state machine is the single most important piece of logic in the feature — without it, every position update inside a zone triggers a notification.

### Can I trigger server-side actions when a user enters a zone?

Yes, via the PostGIS server-side validation API route. When a zone entry fires client-side, send the user's coordinates to a Next.js API route or Supabase Edge Function that runs ST_DWithin to verify membership server-side before triggering the action (unlock a discount, record a compliance check-in, post to a webhook). This prevents client-side spoofing and ensures high-stakes actions are validated independently of the client-side zone checker.

### What is the difference between web geofencing and native app geofencing?

Web geofencing runs in a browser tab and stops detecting location the moment the tab is not active. Native app geofencing (iOS CLRegion, Android Geofencing API, Flutter geolocator package) runs at the OS level and fires zone events in the background even when the device screen is locked. Native also uses significantly less battery because the OS handles zone detection in hardware rather than a JavaScript process. Web geofencing is appropriate for kiosk-style apps, retail store tools, or web apps where users are expected to have the page open; native is required for any use case where detection must happen while the app is not in the foreground.

---

Source: https://www.rapidevelopers.com/app-features/geofencing-notifications
© RapidDev — https://www.rapidevelopers.com/app-features/geofencing-notifications
