Skip to content

Astro

@maplibre-yaml/astro puts maps on an Astro site. Write each map as a YAML file, then show it with <Map> (a map in the page), <FullPageMap> (a map that fills the window) or <Scrollytelling> (a story map). The components load MapLibre for you and work on any static host, GitHub Pages included.

Install the package, then show a map from a YAML file.

In an Astro project (create one with npm create astro@latest -- --template minimal), install the component package, the core library and MapLibre:

Terminal window
npm install @maplibre-yaml/astro @maplibre-yaml/core maplibre-gl@^6

Use the package manager your project already uses; mixing them leaves you with two lockfiles. @maplibre-yaml/astro 0.7 needs @maplibre-yaml/core 0.9 or later, so install and upgrade the two together. maplibre-gl 4, 5 and 6 (6.4.1 or later) all work; 6 is recommended. Astro 4 to 7 are supported (Supported versions).

Keep each map as a YAML file in public/ and point <Map src> at it. This is the recommended setup: the YAML stays a plain file you can edit, and the same file works locally and on GitHub Pages.

  1. Create public/configs/hero-map.yaml:

    public/configs/hero-map.yaml
    type: map
    id: hero-map
    config:
    center: [-74.006, 40.7128]
    zoom: 12
    mapStyle: "https://tiles.openfreemap.org/styles/liberty"
    layers:
    - id: marker
    type: circle
    source:
    type: geojson
    data:
    type: FeatureCollection
    features:
    - type: Feature
    geometry:
    type: Point
    coordinates: [-74.006, 40.7128]
    properties:
    name: "New York City"
    paint:
    circle-radius: 12
    circle-color: "#3b82f6"
    circle-stroke-width: 2
    circle-stroke-color: "#ffffff"
  2. Show it on a page, for example src/pages/index.astro:

    src/pages/index.astro
    ---
    import { Map } from '@maplibre-yaml/astro';
    ---
    <html lang="en">
    <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My map</title>
    </head>
    <body>
    <h1>Welcome</h1>
    <Map src="/configs/hero-map.yaml" height="500px" />
    </body>
    </html>
  3. Run npm run dev and open the address it prints (usually http://localhost:4321). You should see a street map of New York with a blue dot. A map that takes more than a second shows “Loading map…” until the basemap draws.

How it fits together:

  • src is the file’s address on the site, not its path on disk. Files in public/ are served from the site root, so public/configs/hero-map.yaml is /configs/hero-map.yaml.
  • Paths inside the YAML start at public/ too. Put your data next to it (public/data/places.geojson, loaded with url: "/data/places.geojson"). Large files (PMTiles, FlatGeobuf, GeoParquet, COG) work from public/: astro dev, astro preview and GitHub Pages serve the parts of a file the map asks for (Large data).
  • mapStyle is the basemap. Any MapLibre style URL works, or a Protomaps .pmtiles file you keep in public/ (mapStyle: { pmtiles: data/city.pmtiles, flavor: light }), which needs no API key (Host your own basemap).
  • The YAML is checked in the browser. If it has a mistake, the map shows an error card naming the field and line instead. To catch mistakes when the site builds, load the YAML at build time.
  • Everything a map document can do works here: markers:, images:, parameters: (the params panel), legend:, popups, classify:, title:/description: and locale: (Accessibility & languages), the GeoJSON shorthands (location:, route:, …) and format v2 documents. The component hands the whole document to the <ml-map> element, so there is no Astro-specific setup.

height defaults to 400px; class and style style the map’s box. Every prop is listed under Map component API.

<FullPageMap> fills the whole window and adds zoom and reset buttons. Use it for a page that is only a map.

Save the map as public/configs/world-map.yaml:

public/configs/world-map.yaml
type: map
id: world-map
config:
center: [10, 25]
zoom: 1.5
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
layers:
- id: capitals
type: circle
source:
type: geojson
data:
type: FeatureCollection
features:
- type: Feature
geometry: { type: Point, coordinates: [-77.04, 38.91] }
properties: { name: Washington }
- type: Feature
geometry: { type: Point, coordinates: [2.35, 48.86] }
properties: { name: Paris }
- type: Feature
geometry: { type: Point, coordinates: [139.69, 35.69] }
properties: { name: Tokyo }
paint:
circle-radius: 8
circle-color: "#e63946"
circle-stroke-width: 2
circle-stroke-color: "#ffffff"

and give it a page of its own, such as src/pages/map.astro:

src/pages/map.astro
---
import { FullPageMap } from '@maplibre-yaml/astro';
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>World map</title>
</head>
<body style="margin: 0;">
<FullPageMap src="/configs/world-map.yaml" />
</body>
</html>

Keep the viewport <meta>: without it, phones lay the page out about 980px wide and shrink it, so the map and its buttons come out tiny (astro dev warns in the browser console when it is missing). The shorter snippets on this page show only the component; put them on a page like this one or in a layout.

FullPageMap is position: fixed and covers the whole window, header included. Start it below the header with style (here the header is 64px tall):

<header style="height: 64px;">…</header>
<FullPageMap
src="/configs/world-map.yaml"
style="top: 64px; height: calc(100vh - 64px);"
/>

Don’t raise its z-index instead: the map would then cover the header.

The buttons zoom in, zoom out, and reset the view to the one the map opened with (including a fitTo framing of the data). They sit in the map’s top-right corner, and anything else placed there (a legend, the params panel, your own corner slot) stacks below them. Their labels come from the document’s locale: table (NavigationControl.ZoomIn, NavigationControl.ZoomOut, maplibre-yaml.FullPageMap.ResetView).

If the document has its own zoom buttons (controls: { navigation: true }), FullPageMap adds only the reset button, so one YAML file can serve both <Map> and <FullPageMap>. showControls={false} removes all of them.

A layer with a legend: entry (or a classify: block) gets a legend in the top-left corner, as on any map. To move it, add a legend: block with a position to the YAML, or pass showLegend and legendPosition when the document has no legend: block of its own. showLegend needs the document at build time, so it works with config, not src (the component warns at build time if you pass both). legend: false at the top of the document turns the legend off.

layers:
- id: parks
type: fill
legend: { label: Parks, color: "#2e7d32", shape: square }

Legend entries come only from the layers’ legend: fields, the legend block’s items: and classified layers, never from paint colours. When no layer has one, no legend is shown. The layer reference lists the entry’s fields.

  • Story maps in Astro: <Scrollytelling> shows a story map (also called scrollytelling), where chapters of text scroll over a map that moves with them.
  • Astro content collections: a map on every page of a collection, built from a location, region or route in each entry’s frontmatter, or whole map documents kept as validated entries.

Deploying to GitHub Pages (or any subpath)

Section titled “Deploying to GitHub Pages (or any subpath)”

A static Astro build works on any static host. A GitHub Pages project site is served from a subpath (https://<user>.github.io/<repo>/), so set site and base in astro.config.mjs, as Astro’s GitHub Pages guide describes:

astro.config.mjs
import { defineConfig } from "astro/config";
export default defineConfig({
site: "https://<user>.github.io",
base: "/<repo>",
});

Change nothing else. Map, FullPageMap, Scrollytelling and Chapter pass the base to the map, so src="/configs/hero-map.yaml" and the paths inside your YAML load from public/ under it. Check before you push with npm run build && npm run preview, at http://localhost:4321/<repo>/.

Publishing & subpaths has the details (which paths the base applies to, base={false}, large files on GitHub Pages), and the Astro quick start has the GitHub Actions workflow.

What you see after saving a file under astro dev depends on how the page loads it:

You editWhat happens
A YAML file in public/ (<Map src>)Reload the page to see it.
A file read by loadMapConfig / loadScrollytellingConfig, or a feature_ref GeoJSONReload the page to see it.
A content-collection YAML or Markdown fileThe page updates by itself.
The loadGlobalMapConfig fileRestart astro dev: the module that reads it ran once and kept the old file.

Add the optional integration and every row becomes “the page reloads by itself”. It watches the files the loaders read and the YAML, JSON, TOML and GeoJSON under public/:

astro.config.mjs
import { defineConfig } from "astro/config";
import maplibreYaml from "@maplibre-yaml/astro/integration";
export default defineConfig({
integrations: [maplibreYaml()],
});

It also pre-bundles maplibre-gl and @maplibre-yaml/core when the dev server starts. Without that, the first page with a map makes Vite find them late: the terminal says new dependencies optimized … reloading, the console shows 504 (Outdated Optimize Dep), and the page reloads once. The integration acts only under astro dev; builds are the same with or without it. It needs @maplibre-yaml/astro 0.6 or later.

With src, a mistake in the YAML shows up as an error card in the browser. Load the file at build time instead and pass it as config, and the same mistake fails astro build and shows in the astro dev error overlay, so a broken map never reaches your site. Keep these files outside public/, for example in src/configs/:

---
import { Map, loadMapConfig } from '@maplibre-yaml/astro';
// The path starts at the project root (where `astro build` runs).
const config = await loadMapConfig('./src/configs/map.yaml');
---
<Map config={config} height="500px" />

The error names the file, each failing field with its line, and the parser’s warnings, which often explain the error:

Scrollytelling configuration validation failed for ./src/stories/trip.yaml:
error Chapter 3 ("summit"): Unknown key "centre" — did you mean "center"? ("center" is required here.) (line 81, column 5)

A deployed site picks up a change to these files only when it is rebuilt. If you pass both config and src, config wins and the build warns.

To report the problems your own way before failing, catch YAMLLoadError:

---
import { Map, loadMapConfig, YAMLLoadError } from '@maplibre-yaml/astro';
let config;
try {
config = await loadMapConfig('./src/configs/map.yaml');
} catch (error) {
if (error instanceof YAMLLoadError) {
// `errors` is set for validation failures; a missing file or a YAML
// syntax error has only `message` (and the original error as `cause`).
console.error(`${error.filePath}: ${error.message}`);
error.errors?.forEach((e) => console.error(` ${e.message}`)); // also e.path, e.line, e.reason
}
throw error; // fail the build
}
---
<Map config={config} />

To load every map in a folder, use loadFromGlob with Vite’s import.meta.glob:

---
import { loadFromGlob, Map } from '@maplibre-yaml/astro';
import { YAMLParser } from '@maplibre-yaml/core';
// A leading "/" is the project root. A "./" pattern is relative to THIS
// file, so './src/configs/*.yaml' from src/pages/ matches nothing.
const files = import.meta.glob<string>('/src/configs/*.yaml', {
query: '?raw',
import: 'default',
});
const maps = await loadFromGlob(files, (yaml) => YAMLParser.safeParseMapBlock(yaml));
---
{maps.map(({ path, config }) => (
<section>
<h2>{config.id} <small>{path}</small></h2>
<Map config={config} height="400px" />
</section>
))}

If any file fails validation, loadFromGlob throws one YAMLLoadError listing every failing file. A pattern that matches nothing returns an empty array. The <string> type argument tells TypeScript what a ?raw import holds; the older as: 'raw' option still works but Vite warns about it on every build.

For a story, loadScrollytellingConfig(path) does the same (Story maps in Astro). loadYAML(path, schema?) reads any YAML file and, given a zod schema, validates it.

Put your own content over the map: a caption, a link, a credit. Children with slot="top-left", "top-right", "bottom-left" or "bottom-right" go in that corner, stacked with the built-in legend and params panel. A slot="legend" child replaces the built-in legend. Map, FullPageMap and Scrollytelling all take them:

examples/astro/site/src/pages/index.astro
<Map config={ringOfFire} height="480px">
<p slot="top-left" class="caption" data-testid="hero-caption">
<strong>16 volcanoes, one plate boundary</strong>
Hover a dot for its name and height.
</p>
<a slot="bottom-right" class="caption" href="/explore">Explore full screen &rarr;</a>
</Map>

A corner with no content takes no space. On FullPageMap, your top-right content stacks below the zoom and reset buttons.

Inside a {condition && (...)} expression, Astro’s compiler reads a literal slot="top-right" as its own named-slot directive and drops it. Write {...{ slot: "top-right" }} there instead; a literal slot outside an expression is passed through unchanged.

When several layers draw the same data (a fill and its outline, say), define the source once under sources: and name it from each layer:

type: map
id: neighborhood-map
config:
center: [-73.985, 40.674]
zoom: 14
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
sources:
boundary:
type: geojson
url: "/data/boundary.geojson"
layers:
- id: boundary-fill
type: fill
source: boundary
paint:
fill-color: "#3388ff"
fill-opacity: 0.2
- id: boundary-outline
type: line
source: boundary
paint:
line-color: "#3388ff"
line-width: 2

Named and inline sources can be mixed in one document. Every source type and its options are in the sources reference.

Effects (effect: on a layer, such as the tonal hatch) need the effects package on the page with the map. Install @maplibre-yaml/effects and import its register module in a <script>:

---
import { Map } from '@maplibre-yaml/astro';
---
<Map src="/configs/hatched.yaml" height="500px" />
<script>
import '@maplibre-yaml/effects/register';
</script>

The components work with Astro’s client-side router (<ClientRouter /> in your layout’s <head>; <ViewTransitions /> on Astro 4). Each navigation builds the new page’s maps and removes the old ones, and FullPageMap’s buttons work on every visit. This needs @maplibre-yaml/astro 0.6 or later.

Your own page scripts run once per full page load, not on each navigation. Do per-page work in an astro:page-load listener, which fires after the first load and after every navigation:

<script>
document.addEventListener('astro:page-load', () => {
for (const mapEl of document.querySelectorAll('ml-map')) {
// wire this page's maps here
}
});
</script>

The default static output is not affected by this section.

With output: 'server', pages render on request on the deployed server. The components, src, content collections, the builders and loadFromGlob all work there. loadMapConfig, loadScrollytellingConfig, loadGlobalMapConfig and feature_ref don’t: they read files from your project folder when the page renders, and the deployed server has the built site but not your src/ folder. Such pages fail with a 500 and ENOENT in the server log.

Prerender the pages that use them, so they run once at build time:

---
export const prerender = true;
import { Map, loadMapConfig } from '@maplibre-yaml/astro';
const config = await loadMapConfig('./src/configs/map.yaml');
---
<Map config={config} />

Or bundle the YAML into the server with an import, which works on request:

---
import { Map } from '@maplibre-yaml/astro';
import { YAMLParser } from '@maplibre-yaml/core';
import mapYaml from '../configs/map.yaml?raw';
const config = YAMLParser.parseMapBlock(mapYaml); // throws on an invalid document
---
<Map config={config} />

On a site with many maps built from code (see the map builders), set the basemap and default view once:

src/config/maps.yaml
title: "My map site"
defaultMapStyle: "https://tiles.openfreemap.org/styles/liberty"
defaultCenter: [-73.985, 40.674]
defaultZoom: 14

Load it in a shared module, then pass it to any builder as its second argument:

src/lib/map-config.ts
import { loadGlobalMapConfig } from "@maplibre-yaml/astro";
export const globalMapConfig = await loadGlobalMapConfig("./src/config/maps.yaml");
---
import { Map, buildPointMapConfig } from "@maplibre-yaml/astro";
import { globalMapConfig } from "../lib/map-config";
const mapConfig = buildPointMapConfig(
{ location: { coordinates: [-73.983, 40.676], name: "Project site" } },
globalMapConfig, // the basemap and zoom come from here
);
---
<Map config={mapConfig} height="400px" />

Values you pass to the builder win; the global file fills in the rest. The center always comes from the geometry, so defaultCenter is used only when a map has none (such as the fallback of buildMapConfigFromEntry). In astro dev the shared module runs once, so restart the server after editing maps.yaml, or add the integration.

FieldTypeDescription
defaultMapStylestyle URL, or { pmtiles, flavor?, lang? }The basemap for every map: a style URL, or a Protomaps basemap from a .pmtiles file in public/
defaultCenter[lng, lat]The center when a map has no geometry
defaultZoomnumber (0–24)The zoom when the builder isn’t given one
title, descriptionstringYour site’s title and description, for your own use

In an .mdx page, import the component and use it like in an .astro page. MDX needs the @astrojs/mdx integration (npx astro add mdx); Starlight sites already have it.

src/pages/guide.mdx
---
layout: ../layouts/Layout.astro
---
import { Map } from "@maplibre-yaml/astro";
# Recent earthquakes
<Map src="/configs/hero-map.yaml" height="400px" />

The layout gives the page its <head>, with the viewport <meta> phones need; use your site’s own layout. (A layout used by an MDX page receives the page’s frontmatter as Astro.props.frontmatter.)

Each component renders an <ml-map> element on the server, with no client directive; the element upgrades in the browser once the register module loads, and from then on every method and event works as on a plain HTML page. A page script can reach the MapLibre map with the element’s mapReady() and listen to its events (ml-map:load, ml-map:error, ml-map:layer-click, …):

---
import { Map } from '@maplibre-yaml/astro';
---
<Map src="/configs/hero-map.yaml" height="400px" />
<p id="status">Loading…</p>
<script>
import type { MLMap } from '@maplibre-yaml/core/components';
customElements.whenDefined('ml-map').then(async () => {
const mapEl = document.querySelector<MLMap>('ml-map')!;
const status = document.getElementById('status')!;
const map = await mapEl.mapReady(); // the MapLibre map, once loaded
const showZoom = () => (status.textContent = `Zoom ${map.getZoom().toFixed(1)}`);
showZoom();
map.on('moveend', showZoom);
});
</script>

To change the map after it has loaded, give the component an id (it lands on the <ml-map> in place of the generated one) and pass the element a new document with update(): the running map changes in place, keeping its camera, and ml-map:update-applied reports what changed. An invalid document is refused with ml-map:update-rejected and the map kept:

---
import { Map } from '@maplibre-yaml/astro';
---
<Map id="quakes" src="/configs/hero-map.yaml" height="400px" />
<button id="recolour" type="button">Recolour the first layer</button>
<script>
import type { MLMap } from '@maplibre-yaml/core/components';
customElements.whenDefined('ml-map').then(() => {
const mapEl = document.getElementById('quakes') as MLMap;
mapEl.addEventListener('ml-map:update-applied', ({ detail }) =>
console.log('changed', detail.summary.propertiesSet)
);
document.getElementById('recolour')!.addEventListener('click', async () => {
const doc = structuredClone(mapEl.getDocument()!); // the document the map shows
doc.layers![0].paint = { ...doc.layers![0].paint, 'circle-color': '#2563eb' };
const { outcome } = await mapEl.update(doc); // "applied"; the camera stays
console.log(outcome);
});
});
</script>

getState() and setState() read and write the document’s state: values the same way (State and parameters). For a page whose readers pan the map themselves while its document changes, <Map camera="host"> keeps an update from ever moving the camera. Every event, method and property is in the <ml-map> reference; the update rules are under Updating the document.

A project keeps the versions it was created with. Before 1.0, a range such as "@maplibre-yaml/core": "^0.8.1" stays within 0.8.x, so features documented here for a later release are missing until you upgrade. Upgrade core and the Astro package together, then restart astro dev:

Terminal window
npm install @maplibre-yaml/core@latest @maplibre-yaml/astro@latest

What changed in each release is in Upgrading to 0.10 (and its older releases notes) and in the packages’ changelogs on npm.

interface MapProps {
src?: string; // The YAML file's address on the site (a file in public/)
config?: MapBlock; // Or a document loaded at build time. One of the two is required
id?: string; // The <ml-map>'s id, for a page script to reach it (default: generated)
camera?: 'document' | 'host'; // Whether a changed document may move the camera (default 'document')
height?: string; // Default "400px"
class?: string;
style?: string;
base?: string | false; // Where the document's paths start. Default: your site's base
// (import.meta.env.BASE_URL); false = relative to the page
quiet?: boolean; // Hide the on-map notices (default false)
debug?: boolean; // Show author hints wherever the page is served (default false)
}

id and camera are for pages that change the map after it loads (React to the map from a script): id lands on the rendered <ml-map> so document.getElementById(id) reaches its methods and events; camera="host" passes through as the element’s camera attribute, so an update never moves the camera. Both need @maplibre-yaml/astro 0.8 and @maplibre-yaml/core 0.10 or later.

quiet hides the “Part of this map couldn’t load” notice (a data file that 404’d, a layer that failed) and the zero-height hint, for a finished site whose visitors shouldn’t see them. The console still logs every problem, and a fatal one (a document that doesn’t validate, a basemap that never loads) still shows its error card. “Loading map…” is not a notice; hide it with ml-map .ml-map-loading { display: none; }.

debug concerns author hints: a “did you mean”, a key nested under config:, coordinates that look written [latitude, longitude], a value starting with the reserved =. They show only while you develop (in astro dev, on any address, and when the site is served from your computer, as with astro preview on localhost). debug shows them on a deployed page too, while you check it; quiet still wins. The full rules are in Reading what the map tells you.

FullPageMap and Scrollytelling take the same src, config, base, quiet and debug; FullPageMap takes id and camera too.

interface FullPageMapProps {
src?: string;
config?: MapBlock;
id?: string;
camera?: 'document' | 'host';
showControls?: boolean; // Zoom and reset buttons (default true)
showLegend?: boolean; // A legend at legendPosition, when the document has no
// legend: block (default false; needs config)
legendPosition?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; // Default 'top-right'
class?: string;
style?: string;
base?: string | false;
quiet?: boolean;
debug?: boolean;
}

<Scrollytelling> and <Chapter> are on Story maps in Astro.

  • Loaders: loadMapConfig, loadScrollytellingConfig, loadFromGlob, loadYAML, loadGlobalMapConfig, and the YAMLLoadError they throw.
  • Builders for maps from coordinates: buildPointMapConfig and the others on Astro content collections.
  • Collection schemas from @maplibre-yaml/astro/utils, also on Astro content collections.
  • Types for your own components: MapProps, FullPageMapProps, ScrollytellingProps, ChapterProps, the frontmatter shapes LocationPoint, RegionPolygon, RouteLine and FeatureRef, and the builder options (PointMapOptions, RouteMapOptions, …).
import type { MapProps, LocationPoint } from '@maplibre-yaml/astro';

The @maplibre-yaml/astro README lists every export, and the TypeScript API reference is generated from the source.

AstroSupportedNotes
7Yes, tested in CINeeds the Node.js version Astro 7 asks for (22.23.4 or later for Astro 7.4.2; npm install says so if yours is older).
6Yes, tested in CIBuild collection schemas with z from astro/zod.
5Yes, tested in CI
4Yes, tested in CIContent-layer collections need three changes, below.

Astro 6 and 7 need @maplibre-yaml/astro 0.5 or later (earlier versions declare astro: ^4 || ^5, so npm install refuses them). The maplibre-gl peer range is ^4 || ^5 || ^6.4.1. CI builds the example site on every major against the published packages, with astro check, astro build and astro preview, drives it in a browser, and runs a smoke test under astro dev.

Collections and zod. Astro 4 and 5 ship zod 3; Astro 6 and 7 ship zod 4, and the two can’t be mixed. Import z from astro/zod, which works on all four majors; the /utils schema helpers are built from the same module, so they nest in your z.object() on every major. (z from astro:content is the same object, but Astro 6 deprecated that export.) Since @maplibre-yaml/astro 0.6, the getCollectionItemWith*Schema() helpers type entry.data fully on every major.

Astro 4 only. The glob() collection loaders on these pages need three changes:

  • Turn on experimental: { contentLayer: true } in astro.config.mjs.
  • Keep the content config in src/content/config.ts; Astro 4 does not read src/content.config.ts. A one-line export { collections } from "../content.config"; there serves every major.
  • Point the loader’s base outside src/content/. Astro 4’s glob() skips files there, and the folder becomes a legacy collection whose ids keep the extension (park.md).

Also on Astro 4, render pages through a layout with a <head>: a page with no <head> drops a component’s <script> when the component has no <style>.

examples/astro/site is a small site, Ring of Fire, that uses every pattern on these pages. CI builds it and drives it in a browser on Astro 4, 5, 6 and 7. Snippets titled with one of its file names are copied from it, and a test fails if they drift.

PageShows
/<Map config> from loadMapConfig, <Map src>, corner slots
/explore<FullPageMap> with controls, showLegend, markers, a params panel, hover popups
/story<Scrollytelling config>: chapters move the camera, toggle layers, run every action type
/story-runtime<Scrollytelling src>, loaded in the browser
/volcanoesA Markdown collection (glob() + LocationPointSchema), one page per entry via buildMapConfigFromEntry
/galleryA YAML collection of whole map documents (extendSchema(getMapSchema(), …))
/field-notes/…Dated posts on getCollectionItemWithLocationSchema(), one point map each via buildPointMapConfig
/transitions<FullPageMap> and <Map> under <ClientRouter /> view transitions

Run it from a clone with pnpm install && pnpm build, then pnpm --filter @maplibre-yaml/example-astro-site dev. To run the CI check for one major: node scripts/astro-matrix.mjs --majors 7.

Problems every kind of page shares are in Troubleshooting, among them a map with no height, Worker failed to load and maps that 404 once published (on GitHub Pages: set base). These are specific to Astro.

You see a Map file not found card, such as Couldn't load src="public/configs/map.yaml": 404 Not Found., followed by the address the browser asked for.

src is the address on the site, not a path on disk. Leave out public/: public/configs/map.yaml is src="/configs/map.yaml". Also check that the file is in public/ (not src/) and that its name and extension match. On a site with a base, the card adds a hint when the address looks like it is missing the subpath.

Reload the page, or restart astro dev for the loadGlobalMapConfig file; the integration makes every case reload by itself.

Using client:only or client:load on Astro components

Section titled “Using client:only or client:load on Astro components”

You see astro build fail while bundling for the browser (for example "readFile" is not exported by "__vite-browser-external"), or the component doesn’t render.

Map, FullPageMap and Scrollytelling are Astro components, not React, Vue or Svelte components, so client directives don’t apply. Remove them: <Map src="/configs/map.yaml" />.

ReferenceError: HTMLElement is not defined

Section titled “ReferenceError: HTMLElement is not defined”

With @maplibre-yaml/core 0.9.0 or earlier, this happens when a page or your own component imports @maplibre-yaml/core/register in its frontmatter (the --- block): the frontmatter runs on the server while the page builds, where browser elements don’t exist. Later versions don’t throw there, but the import still does nothing in the frontmatter, because the frontmatter never runs in the browser, so the map stays empty. Either way, import it in a <script>, which runs only in the browser (example). The @maplibre-yaml/astro components do this for you.

Every build of a site with a map prints this yellow warning. That chunk is the map engine (maplibre-gl plus maplibre-yaml, about 1.5 MB before compression, loaded only by pages with a map). It is a size notice, not an error, and the build succeeded. To quiet it, raise Vite’s limit in astro.config.mjs: vite: { build: { chunkSizeWarningLimit: 2000 } }.

The components with src or config, above, are the recommended path. Use these routes only when you need something they don’t do.

Without the Astro package: <ml-map> directly

Section titled “Without the Astro package: <ml-map> directly”

If you write your own component around the <ml-map> element, it has to do what the package’s components do for you: load MapLibre’s stylesheet, point maplibre-gl 6 at its worker, register the element, and pass your site’s base.

src/components/MyMap.astro
---
import 'maplibre-gl/dist/maplibre-gl.css'; // page CSS: here, not in the script
interface Props {
src: string;
height?: string;
}
const { src, height = '400px' } = Astro.props;
---
<ml-map src={src} base={import.meta.env.BASE_URL} style={`height: ${height}; display: block;`}></ml-map>
<script>
// maplibre-gl 6 loads its worker from a separate file, which it can't find
// inside a Vite bundle by itself.
import { setWorkerUrl } from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import '@maplibre-yaml/core/register';
setWorkerUrl(workerUrl);
</script>

Use it like <Map>: <MyMap src="/configs/hero-map.yaml" />. Why each line is where it is:

  • The register import goes in the <script>, never the frontmatter (HTMLElement is not defined). If <ml-map> stays an unknown element, this script didn’t run.
  • The stylesheet goes in the frontmatter. There it is page CSS, which Astro puts in the page’s <head> in dev and build alike. Imported from the <script>, astro dev injects it with JavaScript, and a view transition drops it.
  • The worker lines are for maplibre-gl 6. Without them the map reports Worker failed to load. On maplibre-gl 4 or 5, delete them (there is no worker file to import, and the build fails if you keep them). Their order doesn’t matter: since @maplibre-yaml/core 0.7.1, <ml-map> waits for the page’s scripts before it builds its map.
  • base makes src and the paths in the YAML start at your site’s base, as the subpath section needs.

The element’s attributes, events and methods are in the <ml-map> reference.

Build-time parsing without the Astro package

Section titled “Build-time parsing without the Astro package”

To validate at build time with core alone, read the files with import.meta.glob and pass the parsed document to <ml-map config> as JSON. A mistake then fails the build. This is what loadMapConfig does for you.

src/components/BuildTimeMap.astro
---
import 'maplibre-gl/dist/maplibre-gl.css';
import { YAMLParser } from '@maplibre-yaml/core';
interface Props {
configPath: string; // e.g. "/src/configs/hero-map.yaml"
height?: string;
}
const { configPath, height = '400px' } = Astro.props;
const files = import.meta.glob<string>('/src/configs/**/*.yaml', {
query: '?raw',
import: 'default',
eager: true,
});
const yaml = files[configPath];
if (!yaml) throw new Error(`Config not found: ${configPath}`);
const result = YAMLParser.safeParseMapBlock(yaml);
if (!result.success) {
throw new Error(`Invalid config ${configPath}: ${result.errors[0].message}`);
}
---
<ml-map config={JSON.stringify(result.data)} base={import.meta.env.BASE_URL} style={`height: ${height}; display: block;`}></ml-map>
<script>
import { setWorkerUrl } from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import '@maplibre-yaml/core/register';
setWorkerUrl(workerUrl);
</script>

Use it as <BuildTimeMap configPath="/src/configs/hero-map.yaml" />. In astro dev, Vite reloads the page when you save one of these files.

You can write the YAML inside a page, in a template literal, and parse it with YAMLParser.safeParseMapBlock. Avoid it: indentation inside a template literal is easy to break, and the error (“Expected object, got null”) doesn’t point at the cause. If you do, start the YAML right after the opening backtick:

---
import { Map } from '@maplibre-yaml/astro';
import { YAMLParser } from '@maplibre-yaml/core';
const yaml = `type: map
id: simple-map
config:
center: [-74.006, 40.7128]
zoom: 12
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
layers: []`;
const config = YAMLParser.parseMapBlock(yaml); // throws on an invalid document
---
<Map config={config} height="300px" />

These sections now live on other pages. The headings stay so that old links still land here.

<Scrollytelling>: Story maps in Astro, with its component API.

<Chapter>: Story maps in Astro: Chapter component.

Adding geographic data to content collections

Section titled “Adding geographic data to content collections”

Astro content collections: Adding geographic data to content collections.

feature_ref: Astro content collections: GeoJSON feature references.

extendSchema(): Astro content collections: Map documents as collection entries.