May 28, 2026 / Gary Tokman

Build a realtime NYC transit map with MapKit JS and mta-js

Here is a practical pattern for turning the mtaapi.dev vehicle endpoint into a live city map: fetch positions through the SDK, cache them for a few seconds, and paint each bus as a MapKit annotation.

Chelsea
Flatiron
Stuy Town
M23 live vehicle layer

Loading realtime positions

A live vehicle layer makes a transit app feel more useful. Instead of only showing scheduled arrivals, the map can show where each train or bus is right now.

For this demo I used the M23 SBS because it is the bus I live near, and I would love to see where those buses are at all times. Conveniently, the public API already exposes M23 vehicle locations with latitude, longitude, bearing, and route metadata. Because M23 is a crosstown route, we convert the bearing into a compact eastbound or westbound suffix for the map label.

The pattern is intentionally small: the server reads and caches MTA vehicle positions, the browser polls a narrow JSON payload, and MapKit only synchronizes marker annotations.

Read vehicle positions

The SDK keeps the product code small. Ask for a route, limit the response, and normalize the fields you need for the map layer. In production, keep API keys on the server and expose only the narrow data your client needs.

Vehicle responses include route metadata too, so the client can use the route label and official color for each MapKit annotation.

To follow along from a fresh project, create a Next app and install the packages used in the example. @apple/mapkit-loader loads MapKit JS in the browser, and @types/apple-mapkit-js-browser gives TypeScript the MapKit types.

terminal
npx create-next-app@latest realtime-transit-map
cd realtime-transit-map
npm install mta-js @tanstack/react-query @apple/mapkit-loader
npm install -D @types/apple-mapkit-js-browser

If you are using Next.js with Turbopack, add mta-js to transpilePackages. The SDK publishes TypeScript source, and this tells Next to compile it instead of treating node_modules/mta-js/index.ts as an unknown module type.

next.config.mjs
/** @type {import("next").NextConfig} */
const nextConfig = {
transpilePackages: ["mta-js"]
}
export default nextConfig
app/api/vehicles/route.ts
import { MTA } from "mta-js"
export async function GET(request: Request) {
const mta = new MTA({
apiKey: process.env.MTA_API_KEY
})
const route = new URL(request.url).searchParams.get("route") ?? "M23"
const vehicles = await mta.bus.vehicles({
route,
limit: 60
})
const points = vehicles
.filter((vehicle) => vehicle.lat && vehicle.lon)
.map((vehicle) => {
const glyphText = vehicle.route.shortName ?? route
const fallbackTitle = `${glyphText} bus`
const direction = crosstownDirectionFromBearing(vehicle.bearing)
const title = vehicle.vehicleId?.replace(/^MTA\s+/i, "").replace(/^NYCT_/i, "") ?? fallbackTitle
return {
id: vehicle.vehicleId ?? fallbackTitle,
lat: vehicle.lat,
lon: vehicle.lon,
bearing: vehicle.bearing,
title: direction ? `${title} ${direction}` : title,
subtitle: vehicle.destinationName,
color: vehicle.route.color ?? "#1f8fff",
glyphText,
recordedAt: vehicle.recordedAt
}
})
return Response.json(
{ vehicles: points },
{
headers: {
"Cache-Control": "public, s-maxage=10, stale-while-revalidate=5"
}
}
)
}
function crosstownDirectionFromBearing(bearing?: number) {
if (typeof bearing !== "number" || !Number.isFinite(bearing)) return undefined
// M23 is a crosstown route, so label the bus by eastbound or westbound movement.
const radians = (bearing * Math.PI) / 180
return Math.sin(radians) >= 0 ? "E" : "W"
}

The same realtime data is available through the REST API for backend services, cron jobs, static demos, or clients that do not need the JavaScript SDK. View the bus API reference.

Overlay markers in MapKit JS

Poll the route payload

Keep the SDK call in your own server route and let a small React Query hook refresh the JSON every 10 seconds.

use-vehicles.ts
import { useQuery } from "@tanstack/react-query"
export type VehiclePoint = {
id: string
lat: number
lon: number
title: string
subtitle?: string
color: string
glyphText: string
}
type VehicleResponse = {
vehicles: VehiclePoint[]
}
async function fetchVehicles(route: string) {
// Calls the route handler from step 1: app/api/vehicles/route.ts.
const params = new URLSearchParams({ route })
const response = await fetch(`/api/vehicles?${params}`, {
cache: "no-store"
})
if (!response.ok) throw new Error("Unable to load vehicles")
return response.json() as Promise<VehicleResponse>
}
export function useVehicles(route = "M23") {
return useQuery({
queryKey: ["vehicles", route],
queryFn: () => fetchVehicles(route),
refetchInterval: 10_000
})
}

Keep the MapKit token server-side

Create a MapKit JS token in Apple Developer Maps Tokens. For local testing, use restriction type None so localhost can load the map. For production, create a domain-restricted token for your origin, such as yourdomain.com, and add the apex domain separately if you serve both.

app/api/mapkit-token/route.ts
export async function GET() {
return Response.json(
{ token: process.env.MAPKIT_TOKEN },
{
headers: {
"Cache-Control": "private, no-store"
}
}
)
}

Load MapKit once

Save the token as MAPKIT_TOKEN in .env.local. The browser never reads that environment variable directly; this hook fetches the short lived token endpoint and passes the result into @apple/mapkit-loader.

The loader resolves to the mapkit namespace object once the requested libraries are ready. The @types/apple-mapkit-js-browser package provides the TypeScript definitions from DefinitelyTyped.

use-mapkit.ts
import { load } from "@apple/mapkit-loader"
import { useQuery } from "@tanstack/react-query"
type MapKitTokenResponse = {
token: string
}
async function fetchMapKitToken() {
const response = await fetch("/api/mapkit-token")
if (!response.ok) throw new Error("Unable to load MapKit token")
return response.json() as Promise<MapKitTokenResponse>
}
async function loadMapKit() {
const { token } = await fetchMapKitToken()
return load({
token,
language: "en-US",
libraries: ["full-map"]
})
}
export function useMapKit() {
return useQuery({
queryKey: ["mapkit"],
queryFn: loadMapKit,
staleTime: Infinity
})
}

Add the React Query provider

Put a single QueryClientProvider above the map so both data hooks can share the same client. In a small demo, a compact providers component is enough.

providers.tsx
"use client"
import type { ReactNode } from "react"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
const queryClient = new QueryClient()
export function Providers({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
)
}

Sync annotations into the map

The map component receives a loaded mapkit namespace and the latest vehicle payload. Its effects stay focused on the imperative work: create the map once, then replace marker annotations whenever React Query refreshes the vehicles.

Apple documents token initialization and script loading in the MapKit JS docs.

VehicleMap.tsx
"use client"
import { useEffect, useRef } from "react"
import type { Map, MapKit } from "@apple/mapkit-loader"
import { useMapKit } from "./use-mapkit"
import { useVehicles } from "./use-vehicles"
export function VehicleMap() {
const elementRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<Map | null>(null)
const mapkitRef = useRef<MapKit | null>(null)
const { data } = useVehicles()
const { data: mapkit } = useMapKit()
useEffect(() => {
if (!mapkit || !elementRef.current || mapRef.current) return
mapkitRef.current = mapkit
mapRef.current = new mapkit.Map(elementRef.current, {
colorScheme: "dark",
showsMapTypeControl: false
})
}, [mapkit])
useEffect(() => {
const mapkit = mapkitRef.current
if (!mapRef.current || !mapkit || !data?.vehicles.length) return
const annotations = data.vehicles.map((vehicle) => {
const { lat, lon, title, subtitle, color, glyphText } = vehicle
const coordinate = new mapkit.Coordinate(lat, lon)
return new mapkit.MarkerAnnotation(coordinate, {
title,
subtitle,
color,
glyphText
})
})
mapRef.current.removeAnnotations(mapRef.current.annotations)
mapRef.current.addAnnotations(annotations)
mapRef.current.showItems(annotations, { animate: true })
}, [data?.vehicles])
return <div ref={elementRef} className="h-screen" />
}

Tie it together

Render the map inside the providers component so React Query is available before the hooks run.

app/page.tsx
import { Providers } from "./providers"
import { VehicleMap } from "./VehicleMap"
export default function Home() {
return (
<Providers>
<VehicleMap />
</Providers>
)
}

That is the whole shape of a live transit layer: keep MTA credentials and upstream feed details on the server, return a small display ready payload, and let the map focus on rendering. From here you can swap in another bus route, add stop arrivals, or combine vehicles and alerts into a richer user experience.