Free-Tier Address Search & Map Selection | LlamaPress Cookbook
LlamaPress
Cookbook
Forms Stable

Free-Tier Address Search & Map Selection

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.

Proven on intterra-dev 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.

# .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:

# 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.

# 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:

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.

# 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
# 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
# app/controllers/venues_controller.rb
def venue_params
  params.require(:venue).permit(:latitude, :longitude)
end

Run the migration through the container:

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.

<%# 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

// 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

.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