--- title: Build a City Data Dashboard | Plaza Docs description: 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. ## What you’ll use - **Geocoding** to find the city’s coordinates - **PlazaQL** to count amenities, find landmarks, and catalog shops ## Key code ### Geocoding with admin layer filter 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; ``` ### Counting amenities via PlazaQL 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); ``` ### Finding landmarks 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); ``` ### Analyzing shops by type 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, ... } ``` ## How it works **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`. ## Full source The complete app is at [github.com/plazafyi/example-apps/tree/main/city-explorer](https://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`. ## Variations to try **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. ## A note on counts 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.