A Drupal site your team can extend. An API any stack can consume.
gtfs.media is a suite of GPL‑2.0+ modules on plain Drupal 10/11. This page covers how a deployment is put together, what it exposes, and how it fits alongside the site you already run.
How a deployment is set up
A gtfs.media site is a standard Drupal install with the GTFS suite enabled. The gtfs module models the spec as content entities and imports your published zip — import once, re-import forever; updates land in place and manual additions survive. gtfs_rt polls your Protobuf endpoints on per-feed schedules and republishes JSON. The display stack renders kiosks and maps as clients of that same API.
Bay Area deployments can provision every operator's static and realtime feeds from the 511 API with one settings form; several feeds run side by side either way.
# A standard Drupal 10/11 site composer require drupal/gtfs drupal/gtfs_rt drupal/gtfs_display drush en gtfs gtfs_rt gtfs_display # then in the admin UI: # /admin/gtfs add a feed, run the import wizard # /admin/gtfs/rt point at your GTFS-RT endpoints # /admin/gtfs/display create display contexts
Configuration is config
Feeds and display contexts are Drupal config entities. Build them in the admin UI, export them with the rest of your configuration, review them in git, and deploy them through whatever pipeline the site already uses. Content stays content: imported GTFS entities are fieldable and translatable, so you can hang route landing pages or extra metadata off them like any other Drupal content.
The public API is the only API.
Every display and map on the platform is a client of the versioned JSON API — there is no private fast path. If our surfaces can build on it, so can your mobile app, your website, or your data team.
- Protobuf in, JSON out — trip updates, vehicle positions, and service alerts are decoded server-side; consumers never touch Protobuf
- Demand-driven freshness — a display requesting data triggers an async refresh of any stale feed, so busy screens keep feeds hot
- ID mapping hooks — when realtime stop IDs don't match your static feed (they often don't), one hook reconciles them
# Static GTFS, served as JSON GET /gtfs/api/v2/feeds/{feed}/stops/{stop_id} GET /gtfs/api/v2/feeds/{feed}/stops/{stop_id}/routes # Realtime GET /gtfs/api/v2/rt/tripupdates/{stop_id} GET /gtfs/api/v2/rt/vehicles?stop_id={stop_id} GET /gtfs/api/v2/rt/alerts/stops/{stop_id}
// One canonical prediction shape, // however messy the upstream feed { "trip_id": "t_48151", "route_id": "17", "stop_id": "4021", "predicted_arrival_epoch": 1766920841, "delay": 120, "vehicle_id": "2214", "headsign": "Airport via Union Station", "is_last_stop": false }
Response conventions
- Predictable REST — versioned endpoints under
/gtfs/api/v2/, consistent envelopes, feed timestamps in the metadata - Normalized predictions — raw trip updates become one canonical shape, sorted and grouped for departure boards
- Cache-aware — static endpoints are long-lived and page-cacheable; realtime endpoints stay short-lived and cheap to poll
- Permission-controlled — grant anonymous read for public kiosks and apps, or lock endpoints down per role
Integrating with the site you already run
The agency's main website keeps its stack. gtfs.media runs beside it and shows up where you want it.
Embeds
Live maps and boards arrive as iframes at clean URLs, with a postMessage contract for ready/empty states and allowed origins enforced via CSP frame-ancestors. Details on the maps page.
Direct API
Skip the iframe and consume the JSON from your own frontend, app, or data pipeline — the same endpoints the displays poll.
Kiosks are URLs
A display is a page at a predictable path. Any hardware with a browser can show one; there is nothing to install on the device.
Agency-specific hooks
Skins, liveries, ID mapping, and import quirks land in a thin agency module through hooks the core publishes. How custom work is structured.
Put your feed to work.
Clone the modules, stand up a site, and write us when you hit something weird.