Skip to content

Overview

A note before we start

Most spatial features in Rails applications get built by accident. You need a marker on a map, you Google “Rails maps,” and you find a Leaflet tutorial from 2017. You glue it together. It works. Two weeks later your project manager asks for filtering, and you write some Stimulus. A month after that, popups, and the Stimulus grows. By the time the application has anything resembling real GIS features — polygons, spatial joins, drawn shapes — the mapping code has become an island of imperative JavaScript that doesn’t quite match how the rest of the application is built. You write helpers in JavaScript to talk to Rails, helpers in Rails to format data for JavaScript, and the seams between the two are where the bugs live.

The architectural mismatch isn’t anyone’s fault. Leaflet has been around since 2011, JavaScript map libraries are mature, and there hasn’t been an obvious idiomatic answer to “how should this integrate with modern Rails — Hotwire, Turbo, Phlex, importmaps?” Most of the existing material assumes you’ll write the JavaScript side imperatively and the Rails side will hand it some JSON.

This tutorial builds something different. We use MapLibre rather than Leaflet — it’s vector-tile-native, more capable, and the declarative styling fits well with a Rails-shaped architecture. We use the Vera gem, which wraps MapLibre as Phlex components, so maps are declared the same way you’d declare any other UI in your application. We use PostGIS as the spatial database and learn how to use it well — not just ST_Within queries but the underlying mechanics of how spatial indexes work, when they help, and when they don’t. We use Hotwire idiomatically — Turbo morph for state-changing interactions, Turbo Frames for popups and inline content, Turbo Streams for server-pushed updates.

By the end of this tutorial you’ll have built a real application, and you’ll have a working mental model for spatial software that makes the next project — whatever it is — much easier than the first one was.


What you’ll build

By the end of this series you will have built Vera Dispatch Manager — a national field-service operations application with the kind of capabilities you’d expect from a serious GIS-backed SaaS product.

The application has three user roles: field officer, dispatcher, and manager. Each sees a different view of the same underlying data. A field officer sees their own jobs and their next destination. A dispatcher sees their service region’s operations on a live map, with the ability to assign technicians and draw ad-hoc service zones. A manager sees a national dashboard with choropleth views of job density, response-time aggregations, and drilldown into specifics.

The data is synthetic but plausibly realistic: about two thousand technicians, half a million job records, a hundred and fifty depots, all weighted by population across real Australian Bureau of Statistics SA3 boundaries. By Module 7 the application handles half a million jobs without slowing down, using GiST indexes on the geometry columns and vector tiles served from PostGIS. By Module 9 it streams live position updates from technicians via Action Cable so the dispatcher can watch the fleet move across the map in real time.

The chassis — the top bar, sidebar, layouts, authentication, visual styling — ships pre-built as the starter app for the tutorial. We’re not learning to build the chassis; we’re learning to build the spatial work that goes inside it. By the time you’ve finished Module 10, the chassis has been filled in with all the spatial machinery, and you have a complete operations tool.


The journey

Modules 1–3 — Setting up

The first three modules are foundations. Module 1 is framing, no code. Module 2 sets up Rails 8 with PostGIS, adds the Vera gem, and gets the first map on the screen — empty for now, but real, rendered, and styled. Module 3 introduces PostGIS proper: geometry columns, SRIDs, and importing real Australian boundary data via a rake task.

These modules feel slow because most of the visible work is configuration. By the end of Module 3 you have a Rails 8 application running with PostGIS, the Vera gem installed, three seed users, and 360 real polygon boundaries in the database. The map shows them only abstractly so far. The next module is where the loop closes.

Module 4 — Data into maps

The bridge module. We write a controller action that returns PostGIS data as GeoJSON, connect it to the map via the Vera DSL, and render database rows as styled circles. By the end of Module 4 the application shows real depot data on a real map, with paint properties driven by the data. This is the moment most readers say “now I see what’s happening” — the architecture clicks because you’ve followed a single piece of data from PostgreSQL to the browser and back.

Modules 5–6 — Interaction and polygons

Module 5 makes the map interactive. Click handling, popups (both plain and Turbo-Frame-loaded), hover state, URL-driven filtering. A pivotal lesson on Turbo morph, frames, and streams establishes the framework for choosing between them across the rest of the tutorial.

Module 6 brings the polygons alive. We render the SA3 boundaries as fill layers, click them to drill into their data, and build the first choropleth — the classic GIS visualisation where polygon colour encodes a value. The module ends with a subtle lesson on the data-versus-display principle: hover state must not corrupt a choropleth’s encoding because choropleth colour means something specific to the reader.

Module 7 — Working at scale

Where it gets serious. We step the seed data up from fifty thousand jobs to half a million and watch queries slow down. We add a GiST index and watch them speed back up. The chapter on how spatial indexes actually work explains what’s inside the index, why it works, and why some queries don’t benefit from it even when one exists. We cluster points on the map. We implement viewport-bounded loading. We serve vector tiles from PostGIS using ST_AsMVT.

This module is the one where the application stops being a toy. Many readers tell us the spatial-indexing chapter is worth the price of the whole tutorial. It’s also where Audience B — readers new to GIS — start to feel like they understand the substrate of spatial software, not just how to call the right functions.

Module 8 — Drawing and saving

Geoman integration for drawing tools. The dynamic-import pattern that keeps drawing JavaScript out of pages that don’t need it. Saving drawn polygons to PostGIS. And the payoff: querying within drawn shapes — “show me jobs inside this zone I just drew.” The loop closes between user input and spatial query.

Module 9 — Real-time

The headline module. Action Cable broadcasts. A position simulator that nudges technician GPS coordinates toward their next job via a background job. The live dispatch view where vehicles glide across the map in real time. By the end of Module 9 the application looks and feels like a real operations tool — multiple browsers can watch the same fleet move at once.

This is also where the role-aware UI gets its biggest workout. Each role’s real-time view is meaningfully different — a manager watches the fleet nationally, a dispatcher watches their region, a field officer watches their own ETA.

Module 10 — Production

Deployment, performance considerations beyond the database, system testing for spatial features, and a forward-looking chapter on what’s next. Less about new techniques, more about taking what you’ve built and putting it in front of real users.

Appendix — Role-based access control patterns

A deeper dive on the role mechanics that the main tutorial uses inline. For readers who want to understand the policy patterns, the scope-composition rules, and the multi-region cases in more depth.


The finished application

✓ Rails 8 with PostgreSQL + PostGIS
✓ Three roles — manager, dispatcher, field officer
✓ Role-aware navigation, scoped queries, default landing pages
✓ Authentication via Rails 8's built-in auth
✓ Development-mode role switcher in the top bar
✓ Real ABS SA3 boundaries imported (~360 polygons)
✓ ~500,000 plausible synthetic job records
✓ ~2,000 technicians with depot assignments
✓ Interactive map with click-to-drilldown
✓ Hover state via MapLibre feature-state
✓ URL-driven filtering with Ransack and Pagy
✓ Turbo-Frame-loaded popups for rich detail content
✓ Spatial joins with ST_Within, ST_DWithin, ST_Intersects
✓ GiST indexes on geometry columns
✓ Choropleth view with quantile binning
✓ MapLibre clustering for high-density point data
✓ Viewport-bounded loading (load only what's visible)
✓ Vector tiles served from PostGIS via ST_AsMVT
✓ Drawing tools (Geoman) with persisted polygons
✓ Action Cable streaming of live technician positions
✓ Real-time fleet view with sub-second updates
✓ System tests for the map interactions

A note on pacing

The modules are not equal in size or payoff. Modules 1 through 4 build the foundation; the visible results are modest until the last lesson of Module 4 when real data first appears on the map. Module 7 is the first module where most readers say “now I’m glad I did the earlier work” — the indexing chapter genuinely needs the foundations laid in Module 3 to land properly. Module 9 is where everything clicks and the application becomes something you’d actually want to use.

If you find yourself in Module 3 wondering whether all this PostGIS theory is worth it — it is. The moment in Module 7 when you watch a query drop from 2.8 seconds to 312 milliseconds because of one CREATE INDEX statement is the moment the investment pays off.

Stay with it.


Prerequisites

  • Comfortable with Ruby and Rails — you’ve built Rails apps before
  • Familiar with Phlex basics — components, the view_template method, props. The Phlex on Rails tutorial is the right foundation if these are unfamiliar.
  • Familiar with Tailwind CSS — utility classes, responsive prefixes
  • Comfortable with the command line — psql, bin/rails, navigating files in a terminal-friendly editor
  • No prior GIS experience required — that’s what this tutorial is for, particularly Modules 3 and 7
  • No Node.js or build tools — Rails 8 with importmaps throughout
  • PostgreSQL 14+ with PostGIS 3+ — Module 2 walks through the install if it’s new

Series structure

Module Title Focus
1 Why this tutorial exists Framing, no code
2 Setup and your first map Rails 8, PostGIS, gem, first map
3 Geometry in PostgreSQL PostGIS basics, SRIDs, boundary import
4 Data into maps GeoJSON pipeline, sources, layers
5 Interaction Popups, hover, filters, Turbo primitives
6 Polygons and spatial relationships ST_Within, drilldown, choropleth
7 Working at scale Indexes, clustering, vector tiles
8 Drawing and saving Geoman, persisted geometry, ad-hoc queries
9 Real-time Action Cable, live fleet view
10 Production Deployment, testing, what’s next
Appendix 1 Role-based access control Policy patterns, scope composition

Modules vary in length. Module 1 is short and conceptual; Module 7 is substantial and technical. The total is comfortable to work through across several weekends, or — if you’re patient — over the course of an evening per chapter.


A note about format

This tutorial is structured as activities. Most chapters give you something specific to do — type these commands, run this query, observe the result, then we’ll explain why it works that way. Most chapters are 2,000 to 3,000 words plus code, and most of the code blocks are short enough to type rather than paste. We encourage typing. Reading code passes through the eyes; typing it goes through the fingers and stays in the head longer.

Each chapter starts from a known state. If you’re working through the tutorial in order, that state is wherever the previous chapter ended. If you’re skipping ahead — or you got stuck and want to start fresh — every chapter has a starter archive available, accessible as a Git tag on the tutorial’s companion repository. The README of that repository tells you how to clone the right tag for any chapter.

The companion repository also serves as the canonical reference implementation. If a chapter’s code isn’t behaving the way the prose says it should, comparing your code with the chapter’s end tag usually reveals the difference quickly.


Module 1 starts properly with Why this tutorial exists.