May 31, 2026 / Gary Tokman

Build a live NYC subway countdown clock with mta-js

The black LED board on every platform is just realtime arrivals, grouped by direction. Here is how to rebuild it with the mtaapi.dev arrivals endpoint: poll the next trains, sort by minutes, and paint the countdown.

Bedford Av
Brooklyn ยท L
Live
Updated just now

A countdown clock is the most familiar piece of transit UI in New York City. Riders read it without thinking: which train, which way, how many minutes. It is also one of the simplest things to build, because the realtime feed already returns exactly the numbers the board shows.

For this demo I used Bedford Av on the L, the first stop in Brooklyn. The arrivals endpoint returns the next trains with a predicted minutes value and a feed direction, so the whole board is a matter of grouping and sorting.

The pattern is intentionally small: the server reads and caches subway arrivals, the browser polls a narrow JSON payload every 15 seconds, and the board only renders route bullets, destinations, and minutes.

Read the next arrivals

The SDK keeps the product code small. Ask for a stop and route, then normalize the fields the board needs. Keep API keys on the server and expose only the narrow data your client renders.

Each arrival includes route metadata, a destination headsign, a feed direction, and a predicted minutes value, so the server can group trains into the two platform directions and hand the client a board ready payload.

To follow along from a fresh project, create a Next app and install mta-js with React Query for the polling hook and Zod to validate the response on the client.

terminal
npx create-next-app@latest subway-countdown-clock
cd subway-countdown-clock
npm install mta-js @tanstack/react-query zod

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/arrivals/route.ts
import { MTA, type Arrival } from "mta-js"
export async function GET(request: Request) {
const params = new URL(request.url).searchParams
const stopId = params.get("stopId") ?? "L08"
const route = params.get("route") ?? "L"
const mta = new MTA({
apiKey: process.env.MTA_API_KEY
})
const arrivals = await mta.subway.arrivals({ stopId, route, limit: 12 })
return Response.json(
{
station: arrivals[0]?.stop.displayName ?? "Bedford Av",
groups: groupByDirection(arrivals)
},
{
headers: {
"Cache-Control": "public, s-maxage=10, stale-while-revalidate=5"
}
}
)
}
// NYCT subway feeds report north/south even on the east-west L,
// so we bucket arrivals into the two platform directions.
function groupByDirection(arrivals: Arrival[]) {
const order = ["north", "south"] as const
return order
.map((direction) => ({
direction,
arrivals: arrivals
.filter((arrival) => resolveDirection(arrival.direction) === direction)
.filter((arrival) => arrival.minutes >= 0)
.sort((a, b) => a.minutes - b.minutes)
.slice(0, 4)
.map((arrival) => ({
id: arrival.tripId ?? arrival.arrivalTime,
routeLabel: arrival.route.shortName ?? arrival.route.id,
routeColor: arrival.route.color ?? "#a7a9ac",
destination: arrival.destination ?? arrival.headsign ?? "",
minutes: Math.max(0, Math.round(arrival.minutes))
}))
}))
.filter((group) => group.arrivals.length > 0)
}
function resolveDirection(direction: Arrival["direction"]) {
if (direction === "north" || direction === "east") return "north"
if (direction === "south" || direction === "west") return "south"
return "unknown"
}

The same realtime arrivals are available through the REST API for backend services, cron jobs, static signage, or clients that do not need the JavaScript SDK. View the subway API reference.

Render the countdown board

Poll the arrivals payload

Keep the SDK call in your own server route and let a small React Query hook refresh the JSON every 15 seconds. Subway predictions move quickly near the platform, so a short interval keeps the minutes honest without hammering the feed.

Instead of casting the response with as, parse it with a Zod schema. A bad deploy or a feed change becomes a caught error instead of an undefined deep in the render, and the component types come from z.infer so there is a single source of truth.

use-arrivals.ts
import { useQuery } from "@tanstack/react-query"
import { z } from "zod"
const arrivalSchema = z.object({
id: z.string(),
routeLabel: z.string(),
routeColor: z.string(),
destination: z.string(),
minutes: z.number()
})
const groupSchema = z.object({
direction: z.enum(["north", "south"]),
arrivals: z.array(arrivalSchema)
})
const arrivalsResponseSchema = z.object({
station: z.string(),
groups: z.array(groupSchema)
})
export type CountdownArrival = z.infer<typeof arrivalSchema>
export type CountdownGroup = z.infer<typeof groupSchema>
async function fetchArrivals(stopId: string, route: string) {
const params = new URLSearchParams({ stopId, route })
const response = await fetch(`/api/arrivals?${params}`, {
cache: "no-store"
})
if (!response.ok) throw new Error("Unable to load arrivals")
return arrivalsResponseSchema.parse(await response.json())
}
export function useArrivals(stopId = "L08", route = "L") {
return useQuery({
queryKey: ["arrivals", stopId, route],
queryFn: () => fetchArrivals(stopId, route),
refetchInterval: 15_000
})
}

Add the React Query provider

Put a single QueryClientProvider above the board so the hook has a 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>
)
}

Paint the LED display

The board is presentational: map over the grouped directions, render a route bullet in its official color, and show the destination and minutes. A train arriving now reads Now instead of a number, exactly like the platform sign.

Use tabular-nums so the minutes do not shift width as they count down, which keeps the column steady between refreshes.

CountdownBoard.tsx
"use client"
import { useArrivals } from "./use-arrivals"
export function CountdownBoard() {
const { data } = useArrivals()
return (
<div className="rounded-2xl border border-white/10 bg-[#07090d]">
<div className="border-b border-white/10 px-5 py-4 text-base font-semibold text-white">
{data?.station ?? "Bedford Av"}
</div>
{data?.groups.map((group) => (
<section key={group.direction}>
<h3 className="px-5 pt-3 text-xs uppercase tracking-wide text-zinc-500">
{/* Translate the feed's railroad north/south into rider-facing boroughs. */}
{group.direction === "north" ? "Manhattan-bound" : "Brooklyn-bound"}
</h3>
{group.arrivals.map((arrival) => (
<div
key={arrival.id}
className="flex items-center gap-3 border-t border-white/5 px-5 py-3.5"
>
<span
className="flex size-8 items-center justify-center rounded-full font-bold text-black"
style={{ backgroundColor: arrival.routeColor }}
>
{arrival.routeLabel}
</span>
<span className="flex-1 text-zinc-100">{arrival.destination}</span>
<span className="text-2xl font-bold tabular-nums text-amber-400">
{arrival.minutes === 0 ? "Now" : `${arrival.minutes} min`}
</span>
</div>
))}
</section>
))}
</div>
)
}

Tie it together

Render the board inside the providers component so React Query is available before the hook runs.

For an extra bit of polish, the live demo above animates each countdown with NumberFlow so the minutes roll smoothly when a refresh changes them. Drop <NumberFlow value={arrival.minutes} /> in place of the raw number and it just works.

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

That is the whole shape of a countdown clock: keep MTA credentials and feed details on the server, return a small display ready payload grouped by direction, and let the board focus on rendering minutes. From here you can swap in any station, add a second route to the same board, fold in service alerts, or drive a physical sign with the same JSON.