@ubhub/maps
v0.4.0
Published
Official UBHUB SDK for React and Next.js: vector maps with 3D buildings and trees, place search, typed geocoding, reverse geocoding and routing for Mongolia.
Maintainers
Readme
UBHUB Maps SDK for React and Next.js
@ubhub/maps is the official UBHUB SDK for the web. It has three parts:
@ubhub/maps: a typed client for geocoding, reverse geocoding and routing. It works in Node.js, edge runtimes and the browser.@ubhub/maps/next: a route handler that keeps your API key on the server and proxies the endpoints your pages use.@ubhub/maps/react:<UbHubMap>, the<UbHubPlaceSearch>combobox and data hooks.
It mirrors the Flutter SDK ubhub_maps: same endpoints, validation rules, error types and map defaults.
Full documentation: https://developers.ubhub.mn/developers/docs/web-sdk
Installation
npm install @ubhub/maps maplibre-glmaplibre-gl (v4–v6) is only needed for <UbHubMap>. React 18 or 19 is needed for @ubhub/maps/react.
Why a server route?
UBHUB API keys are secret. Anyone who opens your page can read a key shipped to the browser. The API also refuses browser calls from other sites (CORS).
So the key lives in an environment variable on your server. Browser code calls a route in your own app, and that route adds the key.
Browser ──/api/ubhub/v1/...──▶ your Next.js route (adds key) ──▶ developers.ubhub.mn/v1/...Quick start (Next.js App Router), step by step
Full walkthrough with every feature: https://developers.ubhub.mn/developers/docs/web-sdk
Step 1 — Install.
npm install @ubhub/maps maplibre-glStep 2 — Get an API key in your dashboard. Allow basemap (map), geocode (search) and route (routing).
Step 3 — Add the key to .env.local. Never use a NEXT_PUBLIC_ prefix (the key is a server secret).
UBHUB_API_KEY=geo_your_key_hereStep 4 — Add the proxy route in app/api/ubhub/[...path]/route.ts. It keeps the key on the server:
import { createUbHubHandler } from "@ubhub/maps/next";
export const { GET } = createUbHubHandler();Step 5 — Import the styles once (for example in app/layout.tsx):
import "maplibre-gl/dist/maplibre-gl.css";
import "@ubhub/maps/styles.css";Step 6 — Show a map with search. Give the map a height, or it collapses.
"use client";
import { useState } from "react";
import { UbHubMap, UbHubPlaceSearch, UbHubProvider, type UbHubMapController } from "@ubhub/maps/react";
export function Finder() {
const [map, setMap] = useState<UbHubMapController>();
return (
<UbHubProvider proxyUrl="/api/ubhub">
<UbHubPlaceSearch onSelect={(place) => map?.flyTo(place.location)} />
<UbHubMap style={{ height: 480 }} onReady={setMap} />
</UbHubProvider>
);
}Step 7 — Add markers. markers is a list of UbHubMarker; a bare { position } is the default dot.
<UbHubMap
style={{ height: 480 }}
markers={[
{ position: { latitude: 47.9184, longitude: 106.9177 } },
{ position: place.location, id: place.id, icon: { name: "pin", url: "/pin.png" }, label: place.name },
]}
/>Next steps (see the guide for code): clustering (clusterMarkers), shapes (polylines, polygons, circles), taps (onMarkerTap, onClick, onLongPress), infoWindow, and camera (minZoom/maxZoom, cameraBounds, onCameraMove/onCameraIdle).
The proxy route
export const { GET } = createUbHubHandler({
// Optional. Defaults shown.
apiKey: process.env.UBHUB_API_KEY,
baseUrl: "https://developers.ubhub.mn",
endpoints: ["geocode", "route", "basemap"],
timeoutMs: 10_000,
// Recommended in production: only signed-in users may spend your quota.
authorize: async (request) => Boolean(await getSession(request)),
});The proxy forwards only these requests:
/v1/geocodewithq,lat,lonandlimit./v1/routewithorigin,destinationandmode.- The Default and Nova basemap
style.json(with itsbuildingsandtreesoptions), tiles, sprites and fonts. - The building and tree tiles, tree models, and the 3D buildings
tileset.jsonand its tiles.
Anything else returns 404, and unknown query parameters return 400. It also:
- does not forward cookies;
- never follows redirects;
- caps responses at 8 MiB.
It also serves MapLibre's worker script (/maplibre/maplibre-gl-worker.mjs) from your own maplibre-gl install, and the Draco decoder for the 3D buildings (/draco/...) from your @loaders.gl/draco install; this needs the Node.js runtime and uses no API quota.
It rewrites style.json and the 3D tileset.json so that every tile, sprite, font and model URL points back at the proxy. Any resource hosted elsewhere is rejected. The key never appears in a response.
It uses only the standard Request and Response objects, so it also works in Remix, Hono, Bun or any fetch-based server.
If your app runs behind a reverse proxy that changes the host, set publicUrl: "https://example.com/api/ubhub".
Each style.json and tileset.json request counts as one map load against your plan, the same as direct API calls. Tiles, sprites, fonts and models are not charged but are rate limited.
Server-side calls
Use the client directly in route handlers, server actions and server components:
import { UbHubGeo, NotFoundError } from "@ubhub/maps";
const ubhub = new UbHubGeo({ apiKey: process.env.UBHUB_API_KEY! });
const { results } = await ubhub.geocoding.search("Sukhbaatar Square", { limit: 5 });
const nearest = await ubhub.geocoding.reverse({ latitude: 47.9184, longitude: 106.9177 });
try {
const { route } = await ubhub.routing.route({
origin: { latitude: 47.9184, longitude: 106.9177 },
destination: { latitude: 47.922, longitude: 106.9057 },
mode: "driving", // or "walking"
});
console.log(route.distanceMeters, route.durationSeconds, route.geometry);
} catch (error) {
if (error instanceof NotFoundError) console.log("No route between these points.");
else throw error;
}In the browser, create the client with new UbHubGeo({ proxyUrl: "/api/ubhub" }), or use useUbHub() inside a <UbHubProvider>. Passing apiKey in a browser throws.
Options
| Option | Default | Notes |
| --- | --- | --- |
| apiKey | | Server only. Exactly one of apiKey or proxyUrl. |
| proxyUrl | | Path or URL of your createUbHubHandler route. |
| baseUrl | https://developers.ubhub.mn | HTTPS origin; /v1 is appended. http://localhost is allowed for development. |
| timeoutMs | 10000 | One deadline for sending the request and reading the whole body. |
| coalesceRequests | true | Identical calls that are in flight at the same time share one HTTP request. |
| fetch | global fetch | For tests or instrumentation. |
| debug, logger | off | Logs GET /v1/<endpoint> status=<n> duration=<ms>ms only. |
Every method accepts { signal }. Aborting cancels only that caller; other callers sharing the request are not affected. close() aborts everything that is pending.
Validation
Invalid input throws TypeError or RangeError before any request is sent:
querymust be 2–200 characters and not blank.limitmust be 1–20.- Latitude must be from −90 to 90 and longitude from −180 to 180, and both must be finite.
- Coordinates may have at most 15 decimal places.
modemust be"driving"or"walking".
Hooks
const { results, isLoading, error, tooLong } = usePlaceSearch(text, { limit: 5, debounceMs: 350 });
const { data } = useReverseGeocode(clickedPoint); // null or undefined = idle
const { data: directions } = useRoute(from && to ? { origin: from, destination: to, mode: "walking" } : null);Each hook returns { data, error, isLoading }. A newer input aborts the request for the older one, so a late answer never replaces a newer one.
<UbHubMap>
| Prop | Default | Notes |
| --- | --- | --- |
| basemap | "standard" | "nova" for the Nova style. Changing it swaps the style in place. |
| buildings | "flat" | "none", "flat", "extruded", "detailed" (the detailed 3D buildings as extruded parts, MapLibre only) or "3d" (the meshes, needs scene3d). Changing it loads a new style (one map load). With "none", Nova keeps its own flat buildings. |
| trees | "none" | "none", "points", "extruded" (trunk and crown shapes, MapLibre only) or "3d" (tree models, needs scene3d). |
| buildingStyle | — | { color?, outlineColor?, opacity?, heightScale? }. Omitted values keep the basemap's building colors. Applies live. |
| treeStyle | — | { color?, outlineColor?, opacity? } for tree dots. Applies live. |
| showBuildings, showTrees | true | Hide or show the layers live, without a new map load. |
| pitch | 0 | Camera tilt, 0–85. 0 keeps the map flat and locked; above 0 users can also tilt it. Applies live. |
| scene3d | — | The detailed 3D renderer from @ubhub/maps/3d. |
| initialCenter | Ulaanbaatar (47.9184, 106.9177) | Read once, when the map is created. |
| initialZoom | 12 | Read once. Must be within minZoom–maxZoom. |
| minZoom | 0 | How far users can zoom out (0–22). Changes apply live. |
| maxZoom | 16 | How far users can zoom in (0–22). The basemap serves tiles up to zoom 16; above that MapLibre enlarges them. Changes apply live. |
| markers | [] | UbHubMarker[]: { position, id?, icon?, label?, size?, data? }. A bare { position } is the default dot; icon: { name, url } draws a pin. |
| polylines | [] | UbHubPolyline[]: { points, color?, width?, id?, data? }. |
| polygons | [] | UbHubPolygon[]: { rings, fillColor?, fillOpacity?, strokeColor?, strokeWidth?, id?, data? }. |
| circles | [] | UbHubCircle[]: { center, radiusMeters, … } — meter-accurate, scales with zoom. |
| route | — | Convenience line: points (2 or more). |
| clusterMarkers | false | Group markers into count bubbles; clusterRadius, clusterMaxZoom tune it. Tapping a cluster zooms to its expansion zoom (≤16). |
| cameraBounds | — | UbHubBounds restricts panning. Applies live. |
| dragPan, dragRotate, scrollZoom, doubleClickZoom, touchZoomRotate, keyboard | true | Gesture toggles. Apply live. |
| onReady | — | Receives { map, flyTo(point, { zoom }), camera() }. map is the MapLibre instance. |
| onMarkerTap, onPolylineTap, onPolygonTap, onCircleTap | — | Tap on an annotation (with its data). |
| onClick, onLongPress | — | Basemap tap / long-press (right-click or touch). Not fired over a marker or shape. |
| onCameraMove, onCameraIdle | — | Each receives a UbHubCameraPosition ({ center, zoom, bearing, pitch }). |
| infoWindow | — | (marker) => ReactNode rendered in a popup above the tapped marker. Needs react-dom. |
| onError | — | Fires only when the style cannot load. A missing tile just leaves a gap. |
| navigationControl | true | Zoom and compass buttons. |
| mapOptions | — | Extra MapLibre options. The map is 2D by default; set pitch to tilt it. |
| workerUrl | proxy route | MapLibre v6 worker URL; false leaves MapLibre's setting alone. |
Migrating markers from GeoPoint[] (0.1.x → 0.2.0): markers.map((position) => ({ position })). A bare { position } renders the same dot.
Custom marker icons load from a URL: icon: { name: "pin", url: "/pin.png" }. Info windows need react-dom (already present in any React app).
Give the map a height, and import maplibre-gl/dist/maplibre-gl.css once in your app. The map uses MapLibre's attribution control; keep it visible.
MapLibre v6 decodes tiles in a web worker that bundlers do not copy into your build. The proxy route serves it from your installed maplibre-gl at /api/ubhub/maplibre/maplibre-gl-worker.mjs, and <UbHubMap> uses it automatically. With a Content Security Policy, allow worker-src 'self' blob:. To host the worker yourself, pass workerUrl. With output: "standalone", add node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs to outputFileTracingIncludes for the route.
3D buildings and trees
Buildings and trees are separate layers you can style, hide or leave out:
<UbHubMap
buildings="detailed" // none | flat (default) | extruded | detailed | 3d
trees="extruded" // none (default) | points | extruded | 3d
pitch={55}
buildingStyle={{ color: "#1d4ed8", heightScale: 1.5 }}
treeStyle={{ color: "#22c55e" }}
showBuildings={showBuildings} // toggles live
showTrees={showTrees}
style={{ height: 600 }}
/>For the detailed building meshes and tree models (the same 3D view as
developers.ubhub.mn), install the 3D renderer's peers and pass it as scene3d:
npm install @deck.gl/core @deck.gl/layers @deck.gl/geo-layers @deck.gl/mesh-layers @deck.gl/mapbox \
@loaders.gl/core @loaders.gl/3d-tiles @loaders.gl/gltf @loaders.gl/draco @loaders.gl/mvtimport { ubhub3d } from "@ubhub/maps/3d";
<UbHubMap buildings="3d" trees="3d" scene3d={ubhub3d} pitch={55} style={{ height: 600 }} />- The 3D renderer lives in its own entry point. Apps that do not import
@ubhub/maps/3dneed none of these packages, and their bundle does not grow. "detailed"and"extruded"give a close 3D look with MapLibre alone: the detailed buildings as parts (wings, towers, roof steps) each raised to its own height, and trees as a trunk and a crown. Sloped and curved roofs become steps. Where the API has none yet, they fall back to"extruded"buildings and tree"points".- Without
scene3d,"3d"falls back to"detailed"buildings and"extruded"trees, with a console warning. buildingStyle.coloralso colors the 3D meshes (#rgbor#rrggbb). The tree models keep their own colors.- Performance.
"none"leaves a layer and its tiles out entirely. A hidden layer (showBuildings={false}) loads no new tiles. Keep extruded buildings opaque (opacity1). The detailed meshes keep up to 256 MB of GPU memory for the city center. - The Draco decoder for the meshes is served by your proxy route at
/api/ubhub/draco/. With a Content Security Policy, allowworker-src 'self' blob:andscript-src 'wasm-unsafe-eval'. Withoutput: "standalone", addnode_modules/@loaders.gl/draco/dist/**tooutputFileTracingIncludesfor the route.
<UbHubPlaceSearch>
An accessible combobox following the WAI-ARIA 1.2 pattern:
- Keyboard: Arrow keys move through results, Enter picks the highlighted result (or the first one), and Escape closes the list, then clears the text.
- Searching: it searches from 2 characters with a 350 ms debounce and ignores stale responses.
- Status messages: it shows a short status line, such as "No places found." or "Search limit reached. Please try again later.".
Props: onSelect, onError, limit, debounceMs, placeholder, label, disabled, defaultValue and className.
Import @ubhub/maps/styles.css for the default look, or style the ubhub-search* classes yourself. The CSS variables --ubhub-accent, --ubhub-bg, --ubhub-fg, --ubhub-muted, --ubhub-border and --ubhub-hover cover colors.
Errors
Every failure after a request starts is a UbHubError with status, code and requestId. Messages are fixed SDK text; server error text is never copied.
| Error | When |
| --- | --- |
| ValidationError | 400 or 422 |
| AuthenticationError | 401: missing, invalid or expired key |
| PaymentRequiredError | 402: no active plan |
| AuthorizationError | 403: revoked key, or the key lacks this endpoint |
| NotFoundError | 404: for example, no route |
| TimeoutError | 408, or the SDK deadline expired |
| RateLimitError | 429. Has retryAfterMs, limit, remaining and reset. |
| ServerError | 5xx |
| HttpError | Any other status, including redirects |
| NetworkError | The connection failed |
| ResponseError | Invalid JSON, unexpected shape, or a body over 8 MiB |
The SDK never retries; a RateLimitError tells you how long to wait. Quote requestId when you contact support.
Development
npm install
npm test # unit tests (vitest)
npm run typecheck
npm run lint
npm run build # dist/ (ESM, CJS, .d.ts)
npm run check:contract -- ../api_provider/packages/types/openapi.jsonexamples/nextjs is a small Next.js app that uses the local build.
License
MIT
