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
- Create a Mapbox account and a URL-restricted public token for the app's hostname.
- Put
MAPBOX_ACCESS_TOKENin.envand recreate thellamapresscontainer. - Pin Leaflet in
config/importmap.rb; no gem or npm install is needed. - Add the Mapbox Search web component and a coordinate field to the form.
- Add the Stimulus controller below: a selected suggestion, map click, or marker drag all update the same form field.
- 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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/languageoptions 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
- Replace
Venue,venue, and the controller/view paths with the app's resource. - 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. - 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.
- Add Mapbox Search Box options such as
countryorlanguagewhen the audience is known; leaveproximity="ip"only when IP-biased results are desirable. - 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.
- 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