---
title: Free-Tier Address Search & 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]
---

# Free-Tier Address Search & Map Selection

> ⚠️ **Cookbook example — not live code.** (KEEP THIS CALLOUT.) Every code block below
> is an **example snippet**, **not part of the llamapress.ai codebase**, and **not
> running on this server**. This is a reference recipe for a **Leo instance (an AI coding
> agent) to implement in its own app** — read it to understand the pattern, then recreate
> it there.

This is the address-and-map picker proven in Intterra's agency onboarding. A user types
an address, chooses a suggestion, sees the map jump to it, and can fine-tune the location
by clicking the map or dragging its pin. It combines **Mapbox Search Box** for autocomplete
with **Leaflet** and **CARTO/OpenStreetMap** tiles for the map, so it does not require the
Google Maps SDK or a paid mapping gem.

> **Cost model:** the map layer needs no API key. Address autocomplete needs a public
> Mapbox access token and is usage-billed after Mapbox's current free allowance. It is a
> free-tier pattern, not an unlimited-free service. Check current Mapbox pricing and data
> retention terms before shipping. Do **not** replace it with the public OSM Nominatim API:
> Nominatim's usage policy explicitly forbids client-side autocomplete.
>
> **When to use:** a human is choosing one address or map center in a normal form.
> **When not to:** bulk geocoding, high-volume tracking, offline maps, or an app whose core
> product is geocoding.

---

## The 80/20 in one breath

1. Create a Mapbox account and a URL-restricted public token for the app's hostname.
2. Put `MAPBOX_ACCESS_TOKEN` in `.env` and recreate the `llamapress` container.
3. Pin Leaflet in `config/importmap.rb`; no gem or npm install is needed.
4. Add the Mapbox Search web component and a coordinate field to the form.
5. Add the Stimulus controller below: a selected suggestion, map click, or marker drag
   all update the same form field.
6. Permit and save the coordinate field through the app's normal controller/model.

---

## Layer 1 — Environment and import map

Mapbox browser tokens are intentionally public credentials, but they should still be
restricted to the app's production and development URLs in the Mapbox dashboard. Never
put a secret token in HTML.

```bash
# .env (project root, next to docker-compose.yml)
MAPBOX_ACCESS_TOKEN=pk.your_url_restricted_public_token
```

Docker reads `.env` when it creates the container, so recreate the Rails service after
changing it:

```bash
# Run on the Leo box from /home/ubuntu/Leonardo
docker compose up -d --force-recreate llamapress
```

Pin Leaflet directly from a CDN. This works with LlamaPress's importmap setup and does
not require access to the base image's Gemfile or package manager.

```ruby
# config/importmap.rb
pin "leaflet", to: "https://ga.jspm.io/npm:leaflet@1.9.4/dist/leaflet-src.js"
```

`config/importmap.rb` is a single-file Docker bind mount in LlamaPress. After editing it,
sync it into the already-running container or recreate that container:

```bash
docker compose exec -T llamapress sh -c 'cat > /rails/config/importmap.rb' < rails/config/importmap.rb
```

## Layer 2 — Model and controller

Intterra stores a single `"latitude, longitude"` string as `map_center_location`. That is
the smallest retrofit when an app needs only a map center. For new data models, separate
decimal columns are easier to query and validate.

```ruby
# db/migrate/XXXXXXXXXXXXXX_add_location_to_venues.rb
class AddLocationToVenues < ActiveRecord::Migration[7.2]
  def change
    add_column :venues, :latitude, :decimal, precision: 10, scale: 6
    add_column :venues, :longitude, :decimal, precision: 10, scale: 6
  end
end
```

```ruby
# app/models/venue.rb
class Venue < ApplicationRecord
  validates :latitude, numericality: { in: -90..90 }, allow_nil: true
  validates :longitude, numericality: { in: -180..180 }, allow_nil: true
end
```

```ruby
# app/controllers/venues_controller.rb
def venue_params
  params.require(:venue).permit(:latitude, :longitude)
end
```

Run the migration through the container:

```bash
docker compose exec -T llamapress bundle exec rails db:migrate
```

## Layer 3 — The form

The Mapbox script registers `<mapbox-search-box>`. Its `retrieve` event contains GeoJSON
coordinates in **longitude, latitude** order; the Stimulus controller swaps them before
passing them to Leaflet.

```erb
<%# app/views/venues/_form.html.erb %>
<% content_for :head do %>
  <script id="search-js" defer src="https://api.mapbox.com/search-js/v1.2.0/web.js"></script>
<% end %>

<div data-controller="address-map"
     data-address-map-latitude-value="<%= venue.latitude || 39.8283 %>"
     data-address-map-longitude-value="<%= venue.longitude || -98.5795 %>"
     data-address-map-has-location-value="<%= venue.latitude.present? && venue.longitude.present? %>">
  <label class="block text-sm font-medium mb-2">Address or location</label>

  <mapbox-search-box
    access-token="<%= ENV.fetch("MAPBOX_ACCESS_TOKEN") %>"
    placeholder="Search for an address..."
    proximity="ip"
    data-action="retrieve->address-map#select keydown.enter->address-map#preventSubmit">
  </mapbox-search-box>

  <p class="mt-2 text-xs text-gray-500">
    Choose a result, then click the map or drag the pin to fine-tune it.
  </p>

  <%= form.hidden_field :latitude, data: { address_map_target: "latitude" } %>
  <%= form.hidden_field :longitude, data: { address_map_target: "longitude" } %>

  <div data-address-map-target="map"
       class="mt-4 w-full h-96 rounded-lg border border-gray-300 z-0"></div>
</div>
```

If `content_for :head` is not rendered by the app's layout, add
`<%= yield :head %>` inside the layout's `<head>` or place the script tag in that layout.

## Layer 4 — Stimulus controller

```javascript
// app/javascript/controllers/address_map_controller.js
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"

export default class extends Controller {
  static targets = ["map", "latitude", "longitude"]
  static values = {
    latitude: Number,
    longitude: Number,
    hasLocation: Boolean,
    zoom: { type: Number, default: 13 }
  }

  connect() {
    this.ensureLeafletCss()
    this.configureMarkerIcons()

    this.map = L.map(this.mapTarget).setView(
      [this.latitudeValue, this.longitudeValue],
      this.hasLocationValue ? this.zoomValue : 4
    )

    L.tileLayer(
      "https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png",
      {
        attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
        subdomains: "abcd",
        maxZoom: 20
      }
    ).addTo(this.map)

    if (this.hasLocationValue) this.placeMarker(this.latitudeValue, this.longitudeValue)

    this.map.on("click", ({ latlng }) => this.choose(latlng.lat, latlng.lng))

    // Leaflet may initialize before a Turbo-rendered panel has its final size.
    window.setTimeout(() => this.map?.invalidateSize(), 250)
  }

  select(event) {
    const feature = event.detail?.features?.[0]
    const coordinates = feature?.geometry?.coordinates
    if (!coordinates) return

    const [longitude, latitude] = coordinates
    this.choose(latitude, longitude, { recenter: true })
  }

  preventSubmit(event) {
    if (event.key === "Enter") event.preventDefault()
  }

  choose(latitude, longitude, { recenter = false } = {}) {
    this.placeMarker(latitude, longitude)
    if (recenter) this.map.setView([latitude, longitude], this.zoomValue)

    this.latitudeTarget.value = latitude.toFixed(6)
    this.longitudeTarget.value = longitude.toFixed(6)

    // Hidden-field changes do not naturally emit an input event.
    this.latitudeTarget.dispatchEvent(new Event("change", { bubbles: true }))
    this.longitudeTarget.dispatchEvent(new Event("change", { bubbles: true }))
  }

  placeMarker(latitude, longitude) {
    if (this.marker) {
      this.marker.setLatLng([latitude, longitude])
      return
    }

    this.marker = L.marker([latitude, longitude], { draggable: true }).addTo(this.map)
    this.marker.on("dragend", ({ target }) => {
      const point = target.getLatLng()
      this.choose(point.lat, point.lng)
    })
  }

  configureMarkerIcons() {
    delete L.Icon.Default.prototype._getIconUrl
    L.Icon.Default.mergeOptions({
      iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
      iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
      shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png"
    })
  }

  ensureLeafletCss() {
    if (document.getElementById("leaflet-css")) return

    const link = document.createElement("link")
    link.id = "leaflet-css"
    link.rel = "stylesheet"
    link.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
    document.head.appendChild(link)
  }

  disconnect() {
    this.map?.remove()
  }
}
```

`pin_all_from "app/javascript/controllers", under: "controllers"` in the standard
LlamaPress importmap automatically discovers this controller.

---

## Gotchas (the hard-won stuff)

- **"Free" means free allowance, not unlimited use.** Mapbox requires an account and
  token and can bill beyond its current allowance. Set billing alerts and verify current
  pricing before launch.
- **Do not use public Nominatim for autocomplete.** Its policy forbids autocomplete and
  places strict rate, identification, caching, and attribution requirements on permitted
  searches. Self-hosting Nominatim is a different architecture, not this recipe.
- **Check storage rights.** Mapbox Search Box documentation describes returned search
  data as temporary-use data. Confirm the current terms fit any address/coordinate data
  you intend to persist; storing a user's independently entered form value is not the
  same thing as assuming all provider-returned metadata may be retained.
- **Coordinate order differs.** GeoJSON and Mapbox return `[longitude, latitude]`;
  Leaflet accepts `[latitude, longitude]`. Mixing these often produces a valid-looking
  marker on the wrong continent.
- **Keep attribution visible.** Do not hide or remove the OpenStreetMap/CARTO attribution
  Leaflet renders in the map corner.
- **Restrict the public token by URL.** A `pk...` token is visible in page source by
  design. URL restrictions, least privilege, and usage alerts are its protection.
- **Handle empty selections.** A user can type without selecting a suggestion. Only save
  coordinates after `retrieve`, a map click, or a marker drag; validate presence if the
  location is required.
- **Turbo lifecycle matters.** Always remove the Leaflet map in `disconnect()` or Turbo
  visits can leave duplicate maps and listeners behind.
- **CDNs are runtime dependencies.** For strict CSP, offline operation, or stronger
  supply-chain control, vendor the JavaScript, CSS, and marker images into the app.
- **Search Box geography is not universal.** Confirm the current provider coverage for
  the countries your app serves and use `country`/`language` options where appropriate.

---

## Files this pattern touches

```text
.env
config/importmap.rb
db/migrate/XXXXXXXXXXXXXX_add_location_to_venues.rb
app/models/venue.rb
app/controllers/venues_controller.rb
app/views/venues/_form.html.erb
app/javascript/controllers/address_map_controller.js
```

## How to adapt to your schema

1. Replace `Venue`, `venue`, and the controller/view paths with the app's resource.
2. If the existing app already has a single coordinate string, keep it and change
   `choose()` to write `` `${latitude.toFixed(6)}, ${longitude.toFixed(6)}` `` to one
   target. That is the exact compact storage approach used by Intterra.
3. Change the fallback center and zoom for the app's service area. A country-specific
   center is more useful than Intterra's continental-US default.
4. Add Mapbox Search Box options such as `country` or `language` when the audience is
   known; leave `proximity="ip"` only when IP-biased results are desirable.
5. If users need the selected street address as well as coordinates, add a normal
   address field and populate it from the selected feature's current documented
   properties. Keep manual editing possible, and review provider retention terms first.
6. For a small form that needs address autocomplete but no visual confirmation, drop
   Leaflet and the map target. For pin-only selection, drop Mapbox and the search box;
   the CARTO/OpenStreetMap map portion needs no token.

## Provider references

- Mapbox Search Box API: https://docs.mapbox.com/api/search/search-box/
- Mapbox Search JS web guide: https://docs.mapbox.com/mapbox-search-js/guides/search/web/
- Mapbox Search pricing: https://www.mapbox.com/pricing/#search
- OpenStreetMap Nominatim usage policy: https://operations.osmfoundation.org/policies/nominatim/
- OpenStreetMap copyright and attribution: https://www.openstreetmap.org/copyright
