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.

Powered by Mapbase — locations, zones, and postcodes in one search.

Related

Install

$bunx shadcn@latest add https://mapbase.dev/r/real-estate-boilerplate.json

Copies 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,
    },
  })
}