Skip to content

Build a Turn-by-Turn Nav App

A route with "steps": true has everything a navigation screen shows: the maneuvers, the text to speak and when, the banner with the road name and lanes, and per-coordinate congestion. What the API can’t do for you is follow the driver. This tutorial builds that part in TypeScript: match each GPS fix to the route, move from step to step, fire the announcements on time, and ask for a new route when the driver goes somewhere else.

The code runs in a browser or in Node 18+. It only needs fetch.

  • Directions (POST /route) with steps, voice and banner instructions, annotations and approaches
  • Map matching (POST /map-match) to find the road the driver is actually on after leaving the route
  • headings on the new route, so it starts in the direction the car is already moving

Both endpoints are premium: each call counts as 4 requests. The free plan’s 50 premium requests a day are enough to try this, not to drive with it.

Ask for everything a nav screen needs in one request:

Terminal window
curl -X POST https://plaza.fyi/api/v1/route \
-H "x-api-key: pk_live_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"origin": { "type": "Point", "coordinates": [-122.4300, 37.7700] },
"destination": { "type": "Point", "coordinates": [-122.4000, 37.7800] },
"mode": "auto",
"steps": true,
"voice_instructions": true,
"banner_instructions": true,
"annotations": true,
"depart_at": "now",
"voice_units": "imperial",
"approaches": [null, "curb"],
"waypoint_names": ["", "Office"]
}'
FieldWhy it’s here
stepsThe maneuver list. Everything else hangs off it.
voice_instructionsWhat to say on each step, and how far before the maneuver to say it.
banner_instructionsThe road name, ref, signposted destinations and lanes for the top of the screen.
annotationsPer-coordinate speeds, speed limits and congestion on each leg.
depart_at: "now"Fills in the congestion arrays from the time-of-day profiles. Without it, congestion is unknown.
approachescurb on the destination brings the driver to their side of the street. One entry per point, origin first.
waypoint_names”You have arrived at Office” instead of “your destination”.

The response is a GeoJSON Feature. Trimmed to one step, with illustrative values:

{
"type": "Feature",
"geometry": { "type": "LineString", "coordinates": [[-122.4300, 37.7700], "…", [-122.4000, 37.7800]] },
"properties": {
"distance_m": 3410,
"duration_s": 512,
"legs": [
{
"distance_m": 3410,
"duration_s": 512,
"weight": 512,
"summary": "Market Street, Mission Street",
"annotations": {
"distance_m": [41.2, 38.9],
"congestion": ["low", "moderate"],
"congestion_numeric": [22, 47],
"freeflow_speed_kmh": [40.0, 40.0],
"current_speed_kmh": [32.8, 21.2]
}
}
],
"waypoints": [
{ "name": "Haight Street", "location": [-122.4301, 37.7701], "distance_m": 9.4 },
{ "name": "Mission Street", "location": [-122.4001, 37.7799], "distance_m": 6.1 }
],
"steps": [
{
"maneuver": {
"type": "turn",
"modifier": "left",
"instruction": "Turn left onto Market Street",
"location": [-122.4231, 37.7731],
"bearing_before": 85,
"bearing_after": 40
},
"name": "Market Street",
"distance_m": 1300,
"duration_s": 190,
"leg_index": 0,
"geometry": { "type": "LineString", "coordinates": [[-122.4231, 37.7731], "…"] },
"intersections": ["…"],
"voice_instructions": [
{ "distance_along_geometry_m": 804.7, "announcement": "In 0.5 mi, turn right onto Mission Street", "ssml_announcement": "<speak>…</speak>" },
{ "distance_along_geometry_m": 80, "announcement": "Turn right onto Mission Street", "ssml_announcement": "<speak>…</speak>" }
],
"banner_instructions": [
{
"distance_along_geometry_m": 1300,
"primary": { "text": "Mission Street", "type": "turn", "modifier": "right", "components": [{ "text": "Mission Street", "type": "text" }] }
}
]
}
]
}
}

Two things about this shape drive the rest of the code. A step’s voice and banner instructions are about the maneuver at the end of that step, the next thing the driver has to do. And distance_along_geometry_m counts backwards from that maneuver, so you compare it with the distance the driver still has to go on the step. The Directions guide has every field.

Only the fields this app reads:

type LngLat = [number, number];
interface Maneuver {
type: string; // depart, turn, new_name, on_ramp, off_ramp, fork, end_of_road, roundabout, rotary, ...
modifier: string | null;
instruction: string;
location: LngLat;
exit?: number; // roundabout and rotary exit number
}
interface VoiceInstruction {
distance_along_geometry_m: number;
announcement: string;
ssml_announcement: string;
}
interface BannerComponent {
text: string;
type: "text" | "icon" | "delimiter" | "lane" | "exit" | "exit-number";
active?: boolean;
directions?: string[];
}
interface BannerText {
text: string;
type?: string;
modifier?: string;
components: BannerComponent[];
}
interface Banner {
distance_along_geometry_m: number;
primary: BannerText;
secondary?: BannerText;
sub?: BannerText;
}
interface Step {
maneuver: Maneuver;
name: string | null;
distance_m: number;
leg_index: number;
geometry: { type: "LineString"; coordinates: LngLat[] };
exits?: string;
rotary_name?: string;
voice_instructions?: VoiceInstruction[];
banner_instructions?: Banner[];
}
interface Leg {
distance_m: number;
duration_s: number;
annotations?: { congestion: string[] };
}
interface Route {
type: "Feature";
geometry: { type: "LineString"; coordinates: LngLat[] };
properties: { distance_m: number; duration_s: number; steps: Step[]; legs: Leg[] };
}
const API = "https://plaza.fyi/api/v1";
const API_KEY = "pk_live_YOUR_KEY";
async function plaza(path: string, body: unknown): Promise<Response> {
return fetch(`${API}${path}`, {
method: "POST",
headers: { "x-api-key": API_KEY, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
const point = (c: LngLat) => ({ type: "Point", coordinates: c });

One function builds both the first route and every re-route. A trip is the list of stops still ahead plus the destination:

interface Trip {
stops: LngLat[];
destination: LngLat;
}
function routeBody(from: LngLat, trip: Trip, heading?: number) {
const points = trip.stops.length + 2; // origin, stops, destination
const perPoint = <T>(first: T | null, last: T | null): (T | null)[] => [
first,
...Array(points - 2).fill(null),
last,
];
return {
origin: point(from),
destination: point(trip.destination),
...(trip.stops.length > 0 && { waypoints: trip.stops.map(point) }),
mode: "auto",
steps: true,
voice_instructions: true,
banner_instructions: true,
annotations: true,
depart_at: "now",
approaches: perPoint(null, "curb"),
...(heading !== undefined && { headings: perPoint(Math.round(heading) % 360, null) }),
};
}
async function fetchRoute(from: LngLat, trip: Trip, heading?: number): Promise<Route> {
const res = await plaza("/route", routeBody(from, trip, heading));
if (!res.ok) throw new Error(`route failed: ${res.status} ${await res.text()}`);
return res.json();
}

approaches and headings take one entry per point, origin first, with null where you don’t care. Send the wrong count and you get a 422 that says how many it expected.

Each GPS fix gets projected onto the route to answer three questions: which step is the driver on, how far is the next maneuver, and how far is the fix from the route at all.

Each step’s geometry covers the road from its maneuver to the next one, so the step geometries are all you need. Precompute the cumulative length of each:

const EARTH_RADIUS_M = 6371008.8;
const rad = (d: number) => (d * Math.PI) / 180;
function haversine([lng1, lat1]: LngLat, [lng2, lat2]: LngLat): number {
const dLat = rad(lat2 - lat1);
const dLng = rad(lng2 - lng1);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(lat1)) * Math.cos(rad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_M * Math.asin(Math.sqrt(h));
}
function bearing([lng1, lat1]: LngLat, [lng2, lat2]: LngLat): number {
const y = Math.sin(rad(lng2 - lng1)) * Math.cos(rad(lat2));
const x = Math.cos(rad(lat1)) * Math.sin(rad(lat2)) - Math.sin(rad(lat1)) * Math.cos(rad(lat2)) * Math.cos(rad(lng2 - lng1));
return ((Math.atan2(y, x) * 180) / Math.PI + 360) % 360;
}
// Closest point to p on segment a-b. A flat projection is fine at street scale.
function projectOnSegment(p: LngLat, a: LngLat, b: LngLat) {
const k = Math.cos(rad(a[1]));
const [px, py] = [(p[0] - a[0]) * k, p[1] - a[1]];
const [bx, by] = [(b[0] - a[0]) * k, b[1] - a[1]];
const len2 = bx * bx + by * by;
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, (px * bx + py * by) / len2));
const onLine: LngLat = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
return { t, onLine, distance: haversine(p, onLine) };
}
function cumulative(coords: LngLat[]): number[] {
const out = [0];
for (let i = 1; i < coords.length; i++) out.push(out[i - 1] + haversine(coords[i - 1], coords[i]));
return out;
}

The tracker keeps the current step and only looks a few steps ahead. Searching the whole route would snap a driver on a road the route uses twice (out to a dead end and back, say) to the wrong pass.

interface Position {
stepIndex: number;
step: Step;
remainingOnStepM: number; // distance to the maneuver at the end of this step
offRouteM: number; // distance from the fix to the route
onRoute: LngLat; // the fix moved onto the route, for drawing the puck
}
class RouteProgress {
private stepIndex = 0;
private readonly lengths: number[][];
constructor(readonly route: Route) {
this.lengths = route.properties.steps.map((s) => cumulative(s.geometry.coordinates));
}
update(fix: LngLat): Position {
const steps = this.route.properties.steps;
let best = { distance: Infinity, stepIndex: this.stepIndex, along: 0, onLine: fix };
for (let i = this.stepIndex; i < Math.min(steps.length, this.stepIndex + 3); i++) {
const coords = steps[i].geometry.coordinates;
const cum = this.lengths[i];
for (let j = 0; j + 1 < coords.length; j++) {
const p = projectOnSegment(fix, coords[j], coords[j + 1]);
if (p.distance < best.distance) {
const along = cum[j] + p.t * (cum[j + 1] - cum[j]);
best = { distance: p.distance, stepIndex: i, along, onLine: p.onLine };
}
}
}
// Steps only move forward. Going back is a U-turn, and the off-route check handles it.
this.stepIndex = best.stepIndex;
const stepLength = this.lengths[best.stepIndex].at(-1) ?? 0;
return {
stepIndex: best.stepIndex,
step: steps[best.stepIndex],
remainingOnStepM: Math.max(0, stepLength - best.along),
offRouteM: best.distance,
onRoute: best.onLine,
};
}
}

When the driver passes a maneuver, the fix lands on the next step’s line and the tracker moves on. Steps that end the route or reach a stop (arrive, waypoint) are a two-point line at the stop with no length, so the tracker steps over them. You don’t lose anything: the arrival is the maneuver at the end of the step before, so that step’s voice and banner instructions already announce it.

A step’s announcements are sorted farthest first. Each one is due once the remaining distance on the step drops to its distance_along_geometry_m:

class Announcer {
private spoken = new Set<string>();
due(pos: Position): VoiceInstruction | null {
const list = pos.step.voice_instructions ?? [];
let latest: VoiceInstruction | null = null;
list.forEach((v, i) => {
const key = `${pos.stepIndex}:${i}`;
if (pos.remainingOnStepM <= v.distance_along_geometry_m && !this.spoken.has(key)) {
this.spoken.add(key);
latest = v; // after a GPS gap several can be due; say only the nearest
}
});
return latest;
}
}

The first step’s first announcement is the departure (“Head east on Haight Street”), due as soon as navigation starts. Pass announcement to your text-to-speech engine, or ssml_announcement if it reads SSML. The SSML version wraps street names in <say-as interpret-as="address">. The distances inside the text follow voice_units.

In the browser that’s two lines:

function speak(v: VoiceInstruction) {
speechSynthesis.speak(new SpeechSynthesisUtterance(v.announcement));
}

Each step has one banner, shown for the whole step, describing the maneuver at its end. primary is the road you turn onto, secondary its signposted destinations, and sub the lanes:

function renderBanner(pos: Position): string {
const banner = pos.step.banner_instructions?.[0];
if (!banner) return ""; // arrival steps have no banner
const primary = banner.primary.components
.map((c) => {
if (c.type === "icon") return `[${c.text}]`; // a route shield: draw your own from the ref
if (c.type === "exit-number") return `#${c.text}`;
return c.text;
})
.join(" ");
const lanes = (banner.sub?.components ?? [])
.filter((c) => c.type === "lane")
.map((c) => (c.active ? `(${c.directions?.join("/")})` : ` ${c.directions?.join("/")} `))
.join("|");
const distance = `${Math.round(pos.remainingOnStepM)} m`;
return [`${distance} ${primary}`, banner.secondary?.text, lanes].filter(Boolean).join("\n");
}

At a numbered motorway exit the primary line starts with an exit component (the word on the sign in your lang) and an exit-number: “Exit 12A Main Street”. Plaza doesn’t host shield images, so icon components carry the ref text and you draw the shield.

The arrow comes from the next step’s maneuver: type and modifier. The types worth their own icon:

maneuver.typeShow
turn, end_of_roadA turn arrow from modifier. end_of_road means the road doesn’t go on (“At the end of the road, turn left”).
forkA split arrow, slight_left or slight_right.
on_ramp, off_rampA slip road arrow. An off_ramp at a numbered junction has the number in exits.
mergeA merge arrow.
new_name, continueStraight ahead. new_name means the street changes name.
roundabout, rotaryA roundabout with maneuver.exit as the exit count. A rotary has a name in rotary_name.
roundabout_turnA turn at a mini-roundabout.
waypoint, arriveA flag.

If your client switches on maneuver.type, give it a default case. The type list grows: on_ramp, off_ramp, new_name and rotary arrived on 2026-10-26.

With depart_at, each leg’s annotations.congestion has one entry per pair of consecutive coordinates of the leg’s line. Join the leg’s step geometries end to end to get that line back, leaving out the zero-length arrival steps:

const STOP_TYPES = new Set(["arrive", "waypoint", "charging_stop"]);
function legLine(route: Route, legIndex: number): LngLat[] {
const line: LngLat[] = [];
for (const step of route.properties.steps) {
if (step.leg_index !== legIndex || STOP_TYPES.has(step.maneuver.type)) continue;
const coords = step.geometry.coordinates;
const last = line.at(-1);
const first = coords[0];
const sameStart = last !== undefined && last[0] === first[0] && last[1] === first[1];
line.push(...(sameStart ? coords.slice(1) : coords));
}
return line;
}
const COLOURS: Record<string, string> = {
low: "#2e9e44",
moderate: "#f2a900",
heavy: "#e8590c",
severe: "#c92a2a",
unknown: "#868e96",
};
function congestionSegments(route: Route) {
return route.properties.legs.flatMap((leg, legIndex) => {
const levels = leg.annotations?.congestion ?? [];
const line = legLine(route, legIndex);
if (levels.length !== line.length - 1) return []; // shapes don't line up: draw the plain line
return levels.map((level, i) => ({ colour: COLOURS[level] ?? COLOURS.unknown, line: [line[i], line[i + 1]] }));
});
}

These are modelled speeds for the hour and weekday you depart, not live traffic. Plaza doesn’t have a live traffic feed yet.

One fix far from the route means nothing. GPS jumps, especially between tall buildings. Wait for several in a row, and scale the limit with the fix’s reported accuracy:

class OffRouteDetector {
private strikes = 0;
update(pos: Position, accuracyM: number): boolean {
const limit = Math.max(30, 2 * accuracyM);
this.strikes = pos.offRouteM > limit ? this.strikes + 1 : 0;
return this.strikes >= 3;
}
}

With one fix a second, that’s a decision in about three seconds. Thirty metres covers ordinary GPS error and the width of a wide avenue. It won’t catch a turn onto a service road running right beside the route, though. Comparing the GPS course with the road’s direction does; it’s in the variations below.

Before asking for a new route, work out where the driver really is. The raw fix may sit between two roads, and a new route from the wrong one starts with a U-turn. Send the last few fixes to /map-match:

interface MapMatch {
features: { geometry: { type: "Point"; coordinates: LngLat } }[];
matchings: { confidence: number }[];
}
async function snapRecent(fixes: LngLat[]): Promise<{ at: LngLat; heading?: number } | null> {
const recent = fixes.slice(-6);
const res = await plaza("/map-match", {
geometry: { type: "LineString", coordinates: recent },
radiuses: recent.map(() => 40),
mode: "auto", // only roads a car may use, so a fix next to a footpath snaps to the street
});
if (res.status === 404) return null; // a fix had no road within 40 m
if (!res.ok) throw new Error(`map-match failed: ${res.status} ${await res.text()}`);
const body: MapMatch = await res.json();
const snapped = body.features.map((f) => f.geometry.coordinates);
const at = snapped[snapped.length - 1];
const prev = snapped[snapped.length - 2];
// A parked car has no direction. Only send a heading when it moved.
const heading = haversine(prev, at) > 5 ? bearing(prev, at) : undefined;
return { at, heading };
}

The response has one snapped point per fix, in order, and matchings[0].confidence between 0 and 1 (lower when the fixes sit far from any road). A 404 means one of the fixes had no road a car may use within its radius: the driver may be in a car park. In that case use the raw fix.

The new route starts where the driver is, facing the way they’re moving, and visits the stops they haven’t reached yet. leg_index says which leg the driver is on, so the stops from that index onward are still ahead:

async function reroute(trip: Trip, pos: Position, fixes: LngLat[]) {
const remaining: Trip = { stops: trip.stops.slice(pos.step.leg_index), destination: trip.destination };
const snap = await snapRecent(fixes);
const from = snap?.at ?? fixes[fixes.length - 1];
const route = await fetchRoute(from, remaining, snap?.heading);
return { trip: remaining, route };
}

The heading matters. With headings, Plaza snaps the origin to a nearby road that can be driven that way, and the route leaves in that direction. Without it, the new route may start by telling the driver to turn around.

class Navigator {
private progress: RouteProgress;
private announcer = new Announcer();
private offRoute = new OffRouteDetector();
private fixes: LngLat[] = [];
private rerouting = false;
private lastReroute = 0;
constructor(private trip: Trip, route: Route, private render: (text: string) => void) {
this.progress = new RouteProgress(route);
}
async onFix(fix: LngLat, accuracyM: number) {
this.fixes = [...this.fixes.slice(-9), fix];
const pos = this.progress.update(fix);
const voice = this.announcer.due(pos);
if (voice) speak(voice);
this.render(renderBanner(pos));
const away = this.offRoute.update(pos, accuracyM);
const cooledDown = Date.now() - this.lastReroute > 15_000;
if (!away || this.rerouting || !cooledDown) return;
this.rerouting = true;
this.lastReroute = Date.now();
try {
const { trip, route } = await reroute(this.trip, pos, this.fixes);
this.trip = trip;
this.progress = new RouteProgress(route);
this.announcer = new Announcer();
this.offRoute = new OffRouteDetector();
} finally {
this.rerouting = false;
}
}
}
const trip: Trip = { stops: [], destination: [-122.4, 37.78] };
const route = await fetchRoute([-122.43, 37.77], trip);
const nav = new Navigator(trip, route, (text) => console.log(text));
navigator.geolocation.watchPosition(
(p) => nav.onFix([p.coords.longitude, p.coords.latitude], p.coords.accuracy),
(err) => console.error(err),
{ enableHighAccuracy: true },
);

The cooldown matters for your bill as much as for the driver. A re-route is a map-match plus a route, two premium requests, 8 against your allocation. A driver who weaves through a parking lot shouldn’t trigger ten of them.

The route request, through POST /api/v1/query. PlazaQL points are point(lat, lng), and since PlazaQL has no null, per-point options use the ;-separated form, origin first:

route(
origin: point(37.7700, -122.4300),
destination: point(37.7800, -122.4000),
mode: "auto",
steps: true,
voice_instructions: true,
banner_instructions: true,
annotations: true,
depart_at: "now",
voice_units: "imperial",
approaches: ";curb",
waypoint_names: ["", "Office"]
);

Finding the road after leaving the route:

map_match(
points: [point(37.7731, -122.4228), point(37.7733, -122.4224), point(37.7736, -122.4219)],
radiuses: [40, 40, 40],
mode: "auto"
);

And the re-route, facing north-east from the snapped point:

route(
origin: point(37.7736, -122.4219),
destination: point(37.7800, -122.4000),
steps: true,
voice_instructions: true,
banner_instructions: true,
headings: "45;",
approaches: ";curb"
);

Each returns the same body as the REST endpoint, so the client code above works unchanged.

Course check. Most GPS chips report a course. If it differs from the step’s direction by more than 90° for a few fixes while the speed is above walking pace, the driver turned off even if the fix is still close to the route line. Get the step’s direction from bearing() on the segment the fix projected onto.

Lane guidance on screen. The banner’s sub gives the lanes at the next maneuver. For lanes at every junction along the step, read intersections[].lanes on the step; valid marks the lanes that work for the route.

Speed limit display. Project the fix onto legLine() instead of the step lines, and the index of the segment it lands on is the index into the leg’s annotations.maxspeed. Draw it with the step’s speed_limit_sign (mutcd or vienna) in speed_limit_unit.

Another language. Send lang ("de", "ja", "pt-BR"). Instructions, voice and the banner’s exit word follow it. There are 22 languages, and all but English are machine translations nobody has reviewed yet. See Languages.

Walking and cycling. Change mode to foot or bicycle. Voice timing adapts: 100 m and 15 m before the maneuver on foot instead of 500 m and 80 m in a car. Lower the off-route limit to about 15 m, because sidewalks run close together.