{"slug":"free-address-search-and-map-selection","meta":{"title":"Free-Tier Address Search \u0026 Map Selection","slug":"free-address-search-and-map-selection","category":"Forms","summary":"Add Google-Maps-style address autocomplete and a draggable map pin using Intterra's proven Mapbox Search + Leaflet/CARTO pattern, with no paid mapping SDK or new gem.","tags":["address","autocomplete","maps","mapbox","leaflet","openstreetmap","forms","stimulus"],"status":"stable","visibility":"public","source_project":"intterra-dev","layers":["view","stimulus_js","model","controller"]},"body":"# Free-Tier Address Search \u0026 Map Selection\n\n\u003e ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below\n\u003e is an **example snippet**, **not part of the llamapress.ai codebase**, and **not\n\u003e running on this server**. This is a reference recipe for a **Leo instance (an AI coding\n\u003e agent) to implement in its own app** — read it to understand the pattern, then recreate\n\u003e it there.\n\nThis is the address-and-map picker proven in Intterra's agency onboarding. A user types\nan address, chooses a suggestion, sees the map jump to it, and can fine-tune the location\nby clicking the map or dragging its pin. It combines **Mapbox Search Box** for autocomplete\nwith **Leaflet** and **CARTO/OpenStreetMap** tiles for the map, so it does not require the\nGoogle Maps SDK or a paid mapping gem.\n\n\u003e **Cost model:** the map layer needs no API key. Address autocomplete needs a public\n\u003e Mapbox access token and is usage-billed after Mapbox's current free allowance. It is a\n\u003e free-tier pattern, not an unlimited-free service. Check current Mapbox pricing and data\n\u003e retention terms before shipping. Do **not** replace it with the public OSM Nominatim API:\n\u003e Nominatim's usage policy explicitly forbids client-side autocomplete.\n\u003e\n\u003e **When to use:** a human is choosing one address or map center in a normal form.\n\u003e **When not to:** bulk geocoding, high-volume tracking, offline maps, or an app whose core\n\u003e product is geocoding.\n\n---\n\n## The 80/20 in one breath\n\n1. Create a Mapbox account and a URL-restricted public token for the app's hostname.\n2. Put `MAPBOX_ACCESS_TOKEN` in `.env` and recreate the `llamapress` container.\n3. Pin Leaflet in `config/importmap.rb`; no gem or npm install is needed.\n4. Add the Mapbox Search web component and a coordinate field to the form.\n5. Add the Stimulus controller below: a selected suggestion, map click, or marker drag\n   all update the same form field.\n6. Permit and save the coordinate field through the app's normal controller/model.\n\n---\n\n## Layer 1 — Environment and import map\n\nMapbox browser tokens are intentionally public credentials, but they should still be\nrestricted to the app's production and development URLs in the Mapbox dashboard. Never\nput a secret token in HTML.\n\n```bash\n# .env (project root, next to docker-compose.yml)\nMAPBOX_ACCESS_TOKEN=pk.your_url_restricted_public_token\n```\n\nDocker reads `.env` when it creates the container, so recreate the Rails service after\nchanging it:\n\n```bash\n# Run on the Leo box from /home/ubuntu/Leonardo\ndocker compose up -d --force-recreate llamapress\n```\n\nPin Leaflet directly from a CDN. This works with LlamaPress's importmap setup and does\nnot require access to the base image's Gemfile or package manager.\n\n```ruby\n# config/importmap.rb\npin \"leaflet\", to: \"https://ga.jspm.io/npm:leaflet@1.9.4/dist/leaflet-src.js\"\n```\n\n`config/importmap.rb` is a single-file Docker bind mount in LlamaPress. After editing it,\nsync it into the already-running container or recreate that container:\n\n```bash\ndocker compose exec -T llamapress sh -c 'cat \u003e /rails/config/importmap.rb' \u003c rails/config/importmap.rb\n```\n\n## Layer 2 — Model and controller\n\nIntterra stores a single `\"latitude, longitude\"` string as `map_center_location`. That is\nthe smallest retrofit when an app needs only a map center. For new data models, separate\ndecimal columns are easier to query and validate.\n\n```ruby\n# db/migrate/XXXXXXXXXXXXXX_add_location_to_venues.rb\nclass AddLocationToVenues \u003c ActiveRecord::Migration[7.2]\n  def change\n    add_column :venues, :latitude, :decimal, precision: 10, scale: 6\n    add_column :venues, :longitude, :decimal, precision: 10, scale: 6\n  end\nend\n```\n\n```ruby\n# app/models/venue.rb\nclass Venue \u003c ApplicationRecord\n  validates :latitude, numericality: { in: -90..90 }, allow_nil: true\n  validates :longitude, numericality: { in: -180..180 }, allow_nil: true\nend\n```\n\n```ruby\n# app/controllers/venues_controller.rb\ndef venue_params\n  params.require(:venue).permit(:latitude, :longitude)\nend\n```\n\nRun the migration through the container:\n\n```bash\ndocker compose exec -T llamapress bundle exec rails db:migrate\n```\n\n## Layer 3 — The form\n\nThe Mapbox script registers `\u003cmapbox-search-box\u003e`. Its `retrieve` event contains GeoJSON\ncoordinates in **longitude, latitude** order; the Stimulus controller swaps them before\npassing them to Leaflet.\n\n```erb\n\u003c%# app/views/venues/_form.html.erb %\u003e\n\u003c% content_for :head do %\u003e\n  \u003cscript id=\"search-js\" defer src=\"https://api.mapbox.com/search-js/v1.2.0/web.js\"\u003e\u003c/script\u003e\n\u003c% end %\u003e\n\n\u003cdiv data-controller=\"address-map\"\n     data-address-map-latitude-value=\"\u003c%= venue.latitude || 39.8283 %\u003e\"\n     data-address-map-longitude-value=\"\u003c%= venue.longitude || -98.5795 %\u003e\"\n     data-address-map-has-location-value=\"\u003c%= venue.latitude.present? \u0026\u0026 venue.longitude.present? %\u003e\"\u003e\n  \u003clabel class=\"block text-sm font-medium mb-2\"\u003eAddress or location\u003c/label\u003e\n\n  \u003cmapbox-search-box\n    access-token=\"\u003c%= ENV.fetch(\"MAPBOX_ACCESS_TOKEN\") %\u003e\"\n    placeholder=\"Search for an address...\"\n    proximity=\"ip\"\n    data-action=\"retrieve-\u003eaddress-map#select keydown.enter-\u003eaddress-map#preventSubmit\"\u003e\n  \u003c/mapbox-search-box\u003e\n\n  \u003cp class=\"mt-2 text-xs text-gray-500\"\u003e\n    Choose a result, then click the map or drag the pin to fine-tune it.\n  \u003c/p\u003e\n\n  \u003c%= form.hidden_field :latitude, data: { address_map_target: \"latitude\" } %\u003e\n  \u003c%= form.hidden_field :longitude, data: { address_map_target: \"longitude\" } %\u003e\n\n  \u003cdiv data-address-map-target=\"map\"\n       class=\"mt-4 w-full h-96 rounded-lg border border-gray-300 z-0\"\u003e\u003c/div\u003e\n\u003c/div\u003e\n```\n\nIf `content_for :head` is not rendered by the app's layout, add\n`\u003c%= yield :head %\u003e` inside the layout's `\u003chead\u003e` or place the script tag in that layout.\n\n## Layer 4 — Stimulus controller\n\n```javascript\n// app/javascript/controllers/address_map_controller.js\nimport { Controller } from \"@hotwired/stimulus\"\nimport L from \"leaflet\"\n\nexport default class extends Controller {\n  static targets = [\"map\", \"latitude\", \"longitude\"]\n  static values = {\n    latitude: Number,\n    longitude: Number,\n    hasLocation: Boolean,\n    zoom: { type: Number, default: 13 }\n  }\n\n  connect() {\n    this.ensureLeafletCss()\n    this.configureMarkerIcons()\n\n    this.map = L.map(this.mapTarget).setView(\n      [this.latitudeValue, this.longitudeValue],\n      this.hasLocationValue ? this.zoomValue : 4\n    )\n\n    L.tileLayer(\n      \"https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png\",\n      {\n        attribution: '\u0026copy; \u003ca href=\"https://www.openstreetmap.org/copyright\"\u003eOpenStreetMap\u003c/a\u003e contributors \u0026copy; \u003ca href=\"https://carto.com/attributions\"\u003eCARTO\u003c/a\u003e',\n        subdomains: \"abcd\",\n        maxZoom: 20\n      }\n    ).addTo(this.map)\n\n    if (this.hasLocationValue) this.placeMarker(this.latitudeValue, this.longitudeValue)\n\n    this.map.on(\"click\", ({ latlng }) =\u003e this.choose(latlng.lat, latlng.lng))\n\n    // Leaflet may initialize before a Turbo-rendered panel has its final size.\n    window.setTimeout(() =\u003e this.map?.invalidateSize(), 250)\n  }\n\n  select(event) {\n    const feature = event.detail?.features?.[0]\n    const coordinates = feature?.geometry?.coordinates\n    if (!coordinates) return\n\n    const [longitude, latitude] = coordinates\n    this.choose(latitude, longitude, { recenter: true })\n  }\n\n  preventSubmit(event) {\n    if (event.key === \"Enter\") event.preventDefault()\n  }\n\n  choose(latitude, longitude, { recenter = false } = {}) {\n    this.placeMarker(latitude, longitude)\n    if (recenter) this.map.setView([latitude, longitude], this.zoomValue)\n\n    this.latitudeTarget.value = latitude.toFixed(6)\n    this.longitudeTarget.value = longitude.toFixed(6)\n\n    // Hidden-field changes do not naturally emit an input event.\n    this.latitudeTarget.dispatchEvent(new Event(\"change\", { bubbles: true }))\n    this.longitudeTarget.dispatchEvent(new Event(\"change\", { bubbles: true }))\n  }\n\n  placeMarker(latitude, longitude) {\n    if (this.marker) {\n      this.marker.setLatLng([latitude, longitude])\n      return\n    }\n\n    this.marker = L.marker([latitude, longitude], { draggable: true }).addTo(this.map)\n    this.marker.on(\"dragend\", ({ target }) =\u003e {\n      const point = target.getLatLng()\n      this.choose(point.lat, point.lng)\n    })\n  }\n\n  configureMarkerIcons() {\n    delete L.Icon.Default.prototype._getIconUrl\n    L.Icon.Default.mergeOptions({\n      iconRetinaUrl: \"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png\",\n      iconUrl: \"https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png\",\n      shadowUrl: \"https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png\"\n    })\n  }\n\n  ensureLeafletCss() {\n    if (document.getElementById(\"leaflet-css\")) return\n\n    const link = document.createElement(\"link\")\n    link.id = \"leaflet-css\"\n    link.rel = \"stylesheet\"\n    link.href = \"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\"\n    document.head.appendChild(link)\n  }\n\n  disconnect() {\n    this.map?.remove()\n  }\n}\n```\n\n`pin_all_from \"app/javascript/controllers\", under: \"controllers\"` in the standard\nLlamaPress importmap automatically discovers this controller.\n\n---\n\n## Gotchas (the hard-won stuff)\n\n- **\"Free\" means free allowance, not unlimited use.** Mapbox requires an account and\n  token and can bill beyond its current allowance. Set billing alerts and verify current\n  pricing before launch.\n- **Do not use public Nominatim for autocomplete.** Its policy forbids autocomplete and\n  places strict rate, identification, caching, and attribution requirements on permitted\n  searches. Self-hosting Nominatim is a different architecture, not this recipe.\n- **Check storage rights.** Mapbox Search Box documentation describes returned search\n  data as temporary-use data. Confirm the current terms fit any address/coordinate data\n  you intend to persist; storing a user's independently entered form value is not the\n  same thing as assuming all provider-returned metadata may be retained.\n- **Coordinate order differs.** GeoJSON and Mapbox return `[longitude, latitude]`;\n  Leaflet accepts `[latitude, longitude]`. Mixing these often produces a valid-looking\n  marker on the wrong continent.\n- **Keep attribution visible.** Do not hide or remove the OpenStreetMap/CARTO attribution\n  Leaflet renders in the map corner.\n- **Restrict the public token by URL.** A `pk...` token is visible in page source by\n  design. URL restrictions, least privilege, and usage alerts are its protection.\n- **Handle empty selections.** A user can type without selecting a suggestion. Only save\n  coordinates after `retrieve`, a map click, or a marker drag; validate presence if the\n  location is required.\n- **Turbo lifecycle matters.** Always remove the Leaflet map in `disconnect()` or Turbo\n  visits can leave duplicate maps and listeners behind.\n- **CDNs are runtime dependencies.** For strict CSP, offline operation, or stronger\n  supply-chain control, vendor the JavaScript, CSS, and marker images into the app.\n- **Search Box geography is not universal.** Confirm the current provider coverage for\n  the countries your app serves and use `country`/`language` options where appropriate.\n\n---\n\n## Files this pattern touches\n\n```text\n.env\nconfig/importmap.rb\ndb/migrate/XXXXXXXXXXXXXX_add_location_to_venues.rb\napp/models/venue.rb\napp/controllers/venues_controller.rb\napp/views/venues/_form.html.erb\napp/javascript/controllers/address_map_controller.js\n```\n\n## How to adapt to your schema\n\n1. Replace `Venue`, `venue`, and the controller/view paths with the app's resource.\n2. If the existing app already has a single coordinate string, keep it and change\n   `choose()` to write `` `${latitude.toFixed(6)}, ${longitude.toFixed(6)}` `` to one\n   target. That is the exact compact storage approach used by Intterra.\n3. Change the fallback center and zoom for the app's service area. A country-specific\n   center is more useful than Intterra's continental-US default.\n4. Add Mapbox Search Box options such as `country` or `language` when the audience is\n   known; leave `proximity=\"ip\"` only when IP-biased results are desirable.\n5. If users need the selected street address as well as coordinates, add a normal\n   address field and populate it from the selected feature's current documented\n   properties. Keep manual editing possible, and review provider retention terms first.\n6. For a small form that needs address autocomplete but no visual confirmation, drop\n   Leaflet and the map target. For pin-only selection, drop Mapbox and the search box;\n   the CARTO/OpenStreetMap map portion needs no token.\n\n## Provider references\n\n- Mapbox Search Box API: https://docs.mapbox.com/api/search/search-box/\n- Mapbox Search JS web guide: https://docs.mapbox.com/mapbox-search-js/guides/search/web/\n- Mapbox Search pricing: https://www.mapbox.com/pricing/#search\n- OpenStreetMap Nominatim usage policy: https://operations.osmfoundation.org/policies/nominatim/\n- OpenStreetMap copyright and attribution: https://www.openstreetmap.org/copyright\n"}