Skip to content
GuidesBlogPlaygroundDashboard

Build a City Data Dashboard

An Express server that queries Plaza for any city's amenity counts, top landmarks, and shop breakdown, served as a JSON API with a static frontend.

Give this app a city name and it pulls real stats from Plaza — restaurant counts, top landmarks, shop breakdown. An Express backend handles the API calls, and a static HTML page renders the dashboard.

  • Geocoding to find the city’s coordinates
  • PlazaQL to count amenities, find landmarks, and catalog shops

Use layer=admin to prefer city-level results over POIs that share the name:

const geoResult = await plazaGet(
`/geocode?q=${encodeURIComponent(cityName)}&limit=1&layer=admin`
);
const [lng, lat] = geoResult.features[0].geometry.coordinates;

Each amenity type gets its own around query. Run them all in parallel with Promise.all:

const AMENITY_CATEGORIES = [
{ label: "Restaurants", tag: "restaurant" },
{ label: "Cafes", tag: "cafe" },
{ label: "Bars & Pubs", tag: "bar|pub" },
{ label: "Schools", tag: "school" },
{ label: "Hospitals", tag: "hospital" },
{ label: "Parks", tag: "park" },
// ...
];
const amenityPromises = AMENITY_CATEGORIES.map(async (cat) => {
const query = cat.tag === "park"
? `$$ = search(node, leisure: "${cat.tag}").around(distance: 5000, geometry: point(${lat}, ${lng})).count();`
: `$$ = search(node, amenity: "${cat.tag}").around(distance: 5000, geometry: point(${lat}, ${lng})).count();`;
const result = await plazaQuery(query);
return { label: cat.label, count: result.features?.length ?? 0 };
});
const amenities = await Promise.all(amenityPromises);

Query for tourism tags and extract names. Use .limit(50) to cap results:

const landmarkQuery = `
$$ = search(node, tourism: ~"attraction|museum|monument")
.around(distance: 5000, geometry: point(${lat}, ${lng}))
.limit(50);
`;
const result = await plazaQuery(landmarkQuery);
const landmarks = result.features
.map((f) => ({ name: f.properties.name, type: f.properties.tourism }))
.filter((l) => l.name);

Use .group_by().count() to get the breakdown server-side — no client-side grouping needed:

const shopQuery = `$$ = search(node, shop: *).around(distance: 5000, geometry: point(${lat}, ${lng})).group_by(t["shop"]).count();`;
const shopBreakdown = await plazaQuery(shopQuery);
// Returns: { "supermarket": 45, "convenience": 32, "clothes": 28, ... }

Geocoding resolves the city name to coordinates. The layer=admin parameter prefers city-level results over POIs.

PlazaQL counting.count() when you only need the number, a bare query when you want actual features (landmarks, shops). Each amenity type gets its own query with the around filter. Running them in parallel with Promise.all keeps it fast.

Shop analysis uses .group_by(t["shop"]).count() to get counts per shop type directly from the API — no client-side grouping needed. The top 20 types get sent to the frontend.

The Express server acts as a proxy — the API key stays on the server, and the frontend gets a clean JSON response at /api/city?name=Portland.

The complete app is at github.com/plazafyi/example-apps/tree/main/city-explorer.

Built with Express. Run with PLAZA_API_KEY=pk_live_... node server.js and open http://localhost:3000.

Expand the radius. Change 5000 to 10000 to cover a wider area. You’ll hit more features but the queries will take longer.

Add more categories. There are tags for everything — historic=*, sport=*, healthcare=*. Add queries for whatever interests you.

Compare two cities. Hit the API for two cities and render them side by side. Portland vs. Amsterdam. Tokyo vs. Buenos Aires.

Use global directives. If you’re querying multiple amenity types for the same area, use directives to avoid repeating the spatial filter:

#around(distance: 5000, geometry: point(48.85, 2.35))
$$.restaurants = search(amenity: "restaurant").count();
$$.cafes = search(amenity: "cafe").count();
$$.parks = search(leisure: "park").count();

Average ratings by cuisine. If restaurants have rating tags, use aggregation to compare cuisines: search(amenity: "restaurant").within(geometry: $city).group_by(t["cuisine"]).avg(number(t["rating"])).

Add a map. Drop in a MapLibre GL JS map and plot the landmarks as markers. The coordinates are already in the GeoJSON features.

Plaza’s OpenStreetMap data is crowd-sourced. Some cities are mapped obsessively (Germany, Japan), others less so. If a city shows zero universities, that likely means they’re not mapped as amenity=university nodes — not that the city has none. The data is real but not exhaustive everywhere.