All blocks
templates
Real Estate Boilerplate
Property-search page preset: MapbaseProvider + multi-layer autocomplete over a map with boundaries and polylines.
Open playground →Live preview
Find your next home
Search neighborhoods, zones, and postcodes — boundaries and street outlines render on the map as you pick a place.
/v1/autocomplete
Powered by Mapbase — locations, zones, and postcodes in one search.
Related
Install
$
bunx shadcn@latest add https://mapbase.dev/r/real-estate-boilerplate.jsonCopies source into your app and installs npm + registry dependencies.
npm dependencies
mapbasemaplibre-gllucide-reactmotion@tanstack/react-query
Registry dependencies
https://mapbase.dev/r/mapbase-provider.jsonhttps://mapbase.dev/r/autocomplete.jsonhttps://www.mapcn.dev/r/map.json
Source
components/mapbase/real-estate-boilerplate.tsx
"use client"
import * as React from "react"
import {
type CountryCode,
type Layer,
type LocationSuggestion,
type MapbaseCountryCode,
type PlaceSuggestion,
} from "mapbase"
import { useBoundary, useMapbaseClient, useOptionalMapbase } from "mapbase/react"
import { MapbaseProvider } from "@/components/mapbase/mapbase-provider"
import { Autocomplete } from "@/components/mapbase/autocomplete"
import {
Map,
MapControls,
MapMarker,
MarkerContent,
type MapRef,
type MapViewport,
} from "@/components/ui/map"
import { cn } from "@/lib/utils"
import {
hasRenderablePolylines,
LAYER_COLORS,
useMapBoundaryLayer,
useMapPolylinesLayer,
} from "@/lib/mapbase/map-layers"
export type RealEstateBoilerplateProps = {
apiKey: string
baseUrl?: string
country?: CountryCode
className?: string
height?: string | number
}
type Crumb = { name: string; path?: string }
type MapbaseClient = ReturnType<typeof useMapbaseClient>
/** Canonical postcode landing paths — mirror @mapbase/seo-build typedPostcodePath. */
function postcodePath(country: string, code: string): string {
const cc = country.toLowerCase()
const slug = country.toUpperCase() === "IT" ? "cap" : "codigo-postal"
const segment = code.trim().replace(/\s+/g, "-")
return `/${cc}/${slug}/${segment}`
}
/** Canonical zone landing paths — mirror @mapbase/seo-build typedZonePath. */
function zonePath(country: string, uri: string): string {
const cc = country.toLowerCase()
const hub =
country.toUpperCase() === "PT" ? "zonas" : country.toUpperCase() === "ES" ? "zonas" : "zone"
return `/${cc}/${hub}/${uri}`
}
function zoomForSelection(row: LocationSuggestion | null): number {
if (!row?.level) return 10
if (row.level <= 1) return 5
if (row.level === 2) return 7
if (row.level === 3) return 10
if (row.level === 4) return 12
return 14
}
async function fetchLocationPageCrumbs(
client: MapbaseClient,
id: string,
country: MapbaseCountryCode,
signal: AbortSignal,
): Promise<Crumb[]> {
const result = await client.seo.page(
{ identifier: id, country, include_country_prefix: true },
{ signal },
)
if (result.error || !result.data?.breadcrumbs.length) return []
return result.data.breadcrumbs.map((b) => ({
name: b.name,
path: b.path ?? undefined,
}))
}
async function fetchCoordBreadcrumbs(
client: MapbaseClient,
lng: number,
lat: number,
country: MapbaseCountryCode,
signal: AbortSignal,
): Promise<Crumb[]> {
const result = await client.seo.page(
{ lng, lat, country, include_country_prefix: true },
{ signal },
)
if (result.error || !result.data?.breadcrumbs.length) return []
return result.data.breadcrumbs.map((b) => ({
name: b.name,
path: b.path ?? undefined,
}))
}
function appendTerminal(crumbs: Crumb[], terminal: Crumb): Crumb[] {
const last = crumbs.at(-1)
if (last?.name === terminal.name) {
if (terminal.path && !last.path) {
return [...crumbs.slice(0, -1), terminal]
}
return crumbs
}
return [...crumbs, terminal]
}
async function resolveSelectionBreadcrumbs(
client: MapbaseClient,
row: LocationSuggestion,
selectionLabel: string | undefined,
defaultCountry: MapbaseCountryCode,
signal: AbortSignal,
): Promise<Crumb[]> {
const cc = (row.country ?? defaultCountry) as MapbaseCountryCode
if (row.layer === "locations" && row.id) {
const crumbs = await fetchLocationPageCrumbs(client, row.id, cc, signal)
if (crumbs.length) return crumbs
}
if (row.layer === "postcodes" && row.id) {
let crumbs: Crumb[] = []
const pc = await client.postcodes.get(row.id, { include: ["centroid"] }, { signal })
const code = pc.data?.code ?? row.name
const locationId = pc.data?.location_id
if (locationId) {
crumbs = await fetchLocationPageCrumbs(client, locationId, cc, signal)
} else if (row.centroid) {
crumbs = await fetchCoordBreadcrumbs(
client,
row.centroid[0],
row.centroid[1],
cc,
signal,
)
}
return appendTerminal(crumbs, {
name: code,
path: postcodePath(cc, code),
})
}
if (row.layer === "zones") {
let crumbs: Crumb[] = []
if (row.centroid) {
crumbs = await fetchCoordBreadcrumbs(
client,
row.centroid[0],
row.centroid[1],
cc,
signal,
)
}
if (row.uri) {
return appendTerminal(crumbs, {
name: row.name,
path: zonePath(cc, row.uri),
})
}
return crumbs.length ? crumbs : [{ name: row.name }]
}
if (row.centroid) {
const crumbs = await fetchCoordBreadcrumbs(
client,
row.centroid[0],
row.centroid[1],
cc,
signal,
)
if (crumbs.length) return crumbs
}
return [{ name: row.name ?? selectionLabel ?? "Selected place" }]
}
function useSelectionBreadcrumbs(
selectionRow: LocationSuggestion | null,
selectionLabel: string | undefined,
defaultCountry: MapbaseCountryCode,
) {
const client = useMapbaseClient()
const [crumbs, setCrumbs] = React.useState<Crumb[]>([])
const [loading, setLoading] = React.useState(false)
React.useEffect(() => {
if (!selectionRow) {
setCrumbs([])
setLoading(false)
return
}
const controller = new AbortController()
let cancelled = false
setLoading(true)
void resolveSelectionBreadcrumbs(
client,
selectionRow,
selectionLabel,
defaultCountry,
controller.signal,
)
.then((next) => {
if (!cancelled) setCrumbs(next)
})
.catch(() => {
if (!cancelled) {
setCrumbs([
{ name: selectionRow.name ?? selectionLabel ?? "Selected place" },
])
}
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
controller.abort()
}
}, [client, defaultCountry, selectionLabel, selectionRow])
return { crumbs, loading }
}
function SelectionBreadcrumb({
crumbs,
loading,
}: {
crumbs: Crumb[]
loading: boolean
}) {
const [showLoading, setShowLoading] = React.useState(false)
React.useEffect(() => {
if (!loading) {
setShowLoading(false)
return
}
const timer = window.setTimeout(() => setShowLoading(true), 200)
return () => window.clearTimeout(timer)
}, [loading])
if (!crumbs.length && !showLoading) return null
return (
<div className="pointer-events-auto absolute left-3 top-3 z-20 max-w-[calc(100%-1.5rem)]">
<nav
aria-label="Breadcrumb"
className="flex flex-wrap gap-x-2 gap-y-1 rounded-lg bg-black/50 px-3 py-2 text-xs text-white/60 backdrop-blur-md"
>
{showLoading && !crumbs.length ? (
<span className="text-white/50">Loading…</span>
) : (
crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1
return (
<span
key={`${crumb.name}-${i}`}
className="flex items-center gap-2"
>
{i > 0 ? <span className="text-white/30">/</span> : null}
{crumb.path && !isLast ? (
<a
href={crumb.path}
className="transition-colors hover:text-white"
>
{crumb.name}
</a>
) : (
<span className="text-white/80">{crumb.name}</span>
)}
</span>
)
})
)}
</nav>
</div>
)
}
/**
* Property-search page preset: MapbaseProvider + multi-layer autocomplete
* (locations, zones, postcodes) over a map with boundary/polylines preview.
*/
export function RealEstateBoilerplate({
apiKey,
baseUrl,
country = "PT",
className,
height = "560px",
}: RealEstateBoilerplateProps) {
return (
<MapbaseProvider apiKey={apiKey} baseUrl={baseUrl} country={country}>
<RealEstateSearchMap className={className} height={height} />
</MapbaseProvider>
)
}
function RealEstateSearchMap({
className,
height,
}: {
className?: string
height: string | number
}) {
const mapRef = React.useRef<MapRef>(null)
const ctx = useOptionalMapbase()
const defaultCountry = (ctx?.country ?? "PT") as MapbaseCountryCode
const [selection, setSelection] = React.useState<PlaceSuggestion | null>(null)
const selectionRow = (selection?.ref as LocationSuggestion | undefined) ?? null
const { crumbs, loading: breadcrumbsLoading } = useSelectionBreadcrumbs(
selectionRow,
selection?.label,
defaultCountry,
)
const selectedPolylines = selectionRow?.polylines
const showPolylines = hasRenderablePolylines(selectedPolylines)
const fitBbox = selectionRow?.bbox ?? null
const selectedLayer = (selectionRow?.layer ?? "locations") as Layer
const boundaryPlaceId =
!showPolylines ? (selectionRow?.id ?? null) : null
const { boundary, loading: boundaryLoading } = useBoundary({
placeId: boundaryPlaceId,
enabled: Boolean(boundaryPlaceId),
})
useMapPolylinesLayer(
mapRef,
selectedPolylines,
fitBbox,
showPolylines,
"mapbase-realestate-polylines",
{
fillColor: LAYER_COLORS[selectedLayer].fill,
fillOpacity: 0.12,
lineColor: LAYER_COLORS[selectedLayer].line,
lineWidth: 2,
},
)
useMapBoundaryLayer(
mapRef,
boundary && !showPolylines ? boundary : null,
Boolean(boundary && !showPolylines),
0.15,
selectedLayer,
)
const centroid = selectionRow?.centroid
const [viewport, setViewport] = React.useState<MapViewport>({
center: centroid ?? [-9.1393, 38.7223],
zoom: zoomForSelection(selectionRow),
bearing: 0,
pitch: 0,
})
React.useEffect(() => {
if (!centroid) return
setViewport((prev) => ({
...prev,
center: centroid,
zoom: Math.max(prev.zoom, zoomForSelection(selectionRow)),
}))
}, [centroid, selectionRow])
const styleHeight = typeof height === "number" ? `${height}px` : height
return (
<div className={cn("mx-auto w-full max-w-5xl space-y-4", className)}>
<header className="space-y-1 px-1">
<h1 className="text-2xl font-semibold tracking-tight">
Find your next home
</h1>
<p className="text-sm text-muted-foreground">
Search neighborhoods, zones, and postcodes — boundaries and street
outlines render on the map as you pick a place.
</p>
</header>
<div
className="relative isolate w-full overflow-hidden rounded-xl border shadow-sm"
style={{ height: styleHeight }}
>
<Map
ref={mapRef}
viewport={viewport}
onViewportChange={setViewport}
className="absolute inset-0 h-full w-full"
>
<MapControls position="bottom-right" showZoom className="!bottom-24 !right-4" />
{centroid ? (
<MapMarker longitude={centroid[0]} latitude={centroid[1]}>
<MarkerContent>
<div className="size-3 rounded-full bg-primary shadow-md ring-2 ring-background" />
</MarkerContent>
</MapMarker>
) : null}
</Map>
{selectionRow ? (
<SelectionBreadcrumb crumbs={crumbs} loading={breadcrumbsLoading} />
) : null}
{boundaryLoading ? (
<div className="pointer-events-none absolute top-1/2 left-1/2 z-10 -translate-x-1/2 -translate-y-1/2 rounded-full border bg-background/80 px-4 py-2 text-xs text-muted-foreground backdrop-blur-md">
Loading boundary…
</div>
) : null}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 px-3 pb-3">
<div className="pointer-events-auto mx-auto w-full max-w-xl">
<Autocomplete
layers={["locations", "zones", "postcodes"]}
include={["centroid", "bbox", "polylines"]}
variant="inline"
resultsSide="top"
value={selection}
onChange={setSelection}
onSelect={setSelection}
placeholder="Search a neighborhood, zone, or postcode…"
blurb="Powered by Mapbase — locations, zones, and postcodes in one search."
/>
</div>
</div>
</div>
</div>
)
}
lib/mapbase/map-layers.ts
"use client"
import * as React from "react"
import {
polylinesToGeoJson,
type BoundaryGeometry,
type Layer,
type PolygonFeaturesGeom,
} from "mapbase"
import type { MapRef } from "@/components/ui/map"
/** Per-layer map colors for boundary and polyline overlays. */
export const LAYER_COLORS: Record<Layer, { fill: string; line: string }> = {
locations: { fill: "#eca8d6", line: "#ffffff" },
postcodes: { fill: "#7dd3fc", line: "#bae6fd" },
lau: { fill: "#a3e635", line: "#ecfccb" },
zones: { fill: "#fbbf24", line: "#fef3c7" },
}
export type GeoJsonLayerPaint = {
fillColor?: string
fillOpacity?: number
lineColor?: string
lineWidth?: number
lineDasharray?: number[]
}
const DEFAULT_PAINT: Required<Omit<GeoJsonLayerPaint, "lineDasharray">> & {
lineDasharray?: number[]
} = {
fillColor: "#eca8d6",
fillOpacity: 0.15,
lineColor: "#ffffff",
lineWidth: 2,
}
type GeoJsonLayerOptions = {
mapRef: React.RefObject<MapRef | null>
sourceId: string
fillLayerId: string
lineLayerId: string
geometry: BoundaryGeometry | PolygonFeaturesGeom | null | undefined
paint?: GeoJsonLayerPaint
fitBounds?: [number, number, number, number] | null
enabled?: boolean
}
function applyPaintTransition(
map: MapRef,
layerId: string,
prop: string,
value: unknown
) {
try {
map.setPaintProperty?.(layerId, prop, value)
} catch {
/* layer may not exist yet */
}
}
export function useMapGeoJsonLayer({
mapRef,
sourceId,
fillLayerId,
lineLayerId,
geometry,
paint,
fitBounds,
enabled = true,
}: GeoJsonLayerOptions) {
const paintRef = React.useRef({ ...DEFAULT_PAINT, ...paint })
paintRef.current = { ...DEFAULT_PAINT, ...paint }
React.useEffect(() => {
const map = mapRef.current
if (!map || !enabled || !geometry) return
const apply = () => {
const p = paintRef.current
const data = {
type: "Feature" as const,
properties: {},
geometry,
}
const existing = map.getSource(sourceId)
if (existing) {
;(
existing as unknown as { setData: (geojson: unknown) => void }
).setData(data)
} else {
map.addSource(sourceId, { type: "geojson", data })
map.addLayer({
id: fillLayerId,
type: "fill",
source: sourceId,
paint: {
"fill-color": p.fillColor,
"fill-opacity": 0,
},
})
map.addLayer({
id: lineLayerId,
type: "line",
source: sourceId,
paint: {
"line-color": p.lineColor,
"line-width": p.lineWidth,
"line-opacity": 0,
...(p.lineDasharray ? { "line-dasharray": p.lineDasharray } : {}),
},
})
window.requestAnimationFrame(() => {
applyPaintTransition(map, fillLayerId, "fill-opacity", p.fillOpacity)
applyPaintTransition(map, lineLayerId, "line-opacity", 1)
})
}
applyPaintTransition(map, fillLayerId, "fill-opacity", p.fillOpacity)
applyPaintTransition(map, fillLayerId, "fill-color", p.fillColor)
applyPaintTransition(map, lineLayerId, "line-color", p.lineColor)
if (fitBounds) {
map.fitBounds(
[
[fitBounds[0], fitBounds[1]],
[fitBounds[2], fitBounds[3]],
],
{ padding: 48, duration: 700, maxZoom: 14 }
)
}
}
if (map.isStyleLoaded()) apply()
else map.once("load", apply)
return () => {
const m = mapRef.current
if (!m) return
if (m.getLayer(lineLayerId)) m.removeLayer(lineLayerId)
if (m.getLayer(fillLayerId)) m.removeLayer(fillLayerId)
if (m.getSource(sourceId)) m.removeSource(sourceId)
}
}, [
mapRef,
sourceId,
fillLayerId,
lineLayerId,
geometry,
fitBounds,
enabled,
paint?.fillColor,
paint?.fillOpacity,
paint?.lineColor,
paint?.lineWidth,
])
}
/** True when encoded polylines decode to drawable polygon geometry. */
export function hasRenderablePolylines(
polylines: string[] | null | undefined
): boolean {
if (!polylines?.length) return false
return polylinesToGeoJson(polylines) != null
}
/** First non-empty, renderable polyline set from the given sources. */
export function pickRenderablePolylines(
sources: (string[] | null | undefined)[]
): string[] | null {
for (const polylines of sources) {
if (hasRenderablePolylines(polylines)) return polylines!
}
return null
}
export function useMapPolylinesLayer(
mapRef: React.RefObject<MapRef | null>,
polylines: string[] | null | undefined,
fitBounds: [number, number, number, number] | null | undefined,
enabled = true,
idPrefix = "mapbase-polylines",
paint?: GeoJsonLayerPaint
) {
const geometry = React.useMemo(
() =>
hasRenderablePolylines(polylines) ? polylinesToGeoJson(polylines!) : null,
[polylines]
)
useMapGeoJsonLayer({
mapRef,
sourceId: idPrefix,
fillLayerId: `${idPrefix}-fill`,
lineLayerId: `${idPrefix}-line`,
geometry,
fitBounds,
enabled: enabled && Boolean(geometry),
paint,
})
}
export function useMapBoundaryLayer(
mapRef: React.RefObject<MapRef | null>,
boundary: {
geometry: BoundaryGeometry
bbox: [number, number, number, number]
} | null,
enabled = true,
fillOpacity = 0.15,
layer: Layer = "locations"
) {
const colors = LAYER_COLORS[layer]
useMapGeoJsonLayer({
mapRef,
sourceId: "mapbase-boundary",
fillLayerId: "mapbase-boundary-fill",
lineLayerId: "mapbase-boundary-line",
geometry: boundary?.geometry ?? null,
fitBounds: boundary?.bbox ?? null,
enabled: enabled && Boolean(boundary),
paint: {
fillColor: colors.fill,
fillOpacity,
lineColor: colors.line,
lineWidth: 2,
},
})
}