Skip to content

Astro content collections

This page shows how to put maps on the pages of an Astro content collection, using content collection schemas and map builder utilities. Whether you’re creating a travel blog, restaurant reviews, or hiking guides, maplibre-yaml provides simple patterns for adding geographic context.

There are two ways to use maps with a content collection:

  • Geographic data in your entries. A blog post, project page or trail guide carries a location, a region, a route, or a reference to a feature in a GeoJSON file in its frontmatter, and its page builds a map from it. Most of this page is about this way.
  • Whole map documents as entries. Each entry is a map (or a story) YAML file, validated at build time. See Map documents as collection entries.

The first way uses three key concepts:

  1. Content Collection Schemas - Pre-built Zod schemas that add location/region/route fields to your collection item frontmatter
  2. Map Builder Utilities - Functions that convert simple geographic data into full map configurations
  3. Global Configuration - Site-wide map style settings that individual posts can inherit

Choose the schema that matches your content type:

src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { getCollectionItemWithLocationSchema } from "@maplibre-yaml/astro/utils";
export const collections = {
posts: defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/collections/posts" }),
schema: getCollectionItemWithLocationSchema(),
}),
};

A glob() loader works on Astro 5, 6 and 7 as written. On Astro 4 it needs experimental: { contentLayer: true }, the config file at src/content/config.ts, and a base outside src/content/ (see Supported versions). Loader entries have an id (the file name without its extension) and render with render() from astro:content; the legacy type: "content" API (post.slug, post.render()) is gone in Astro 6.

src/collections/posts/my-trip.md
---
title: "My Trip to Paris"
pubDate: 2024-03-15
location:
coordinates: [2.3522, 48.8566]
name: "Paris, France"
zoom: 12
---
We arrived in Paris on a crisp spring morning...
src/pages/posts/[...slug].astro
---
import { getCollection, render } from 'astro:content';
import { Map, buildPointMapConfig } from '@maplibre-yaml/astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map(post => ({
params: { slug: post.id },
props: { post }
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
// Build map config if location exists
const mapConfig = post.data.location
? buildPointMapConfig({
location: post.data.location,
mapStyle: 'https://tiles.openfreemap.org/styles/liberty'
})
: null;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{post.data.title}</title>
</head>
<body>
<article>
<h1>{post.data.title}</h1>
{mapConfig && (
<Map config={mapConfig} height="300px" />
)}
<Content />
</article>
</body>
</html>

Best for: Restaurant reviews, place spotlights, single-destination content.

import { getCollectionItemWithLocationSchema } from "@maplibre-yaml/astro/utils";
const schema = getCollectionItemWithLocationSchema();

Frontmatter example:

---
title: "Best Coffee in Brooklyn"
pubDate: 2024-03-15
location:
coordinates: [-73.9857, 40.6892]
name: "Devoción Coffee"
zoom: 16
markerColor: "#8B4513"
---

Best for: Travel itineraries, city guides, comparison posts.

import { getCollectionItemWithLocationsSchema } from "@maplibre-yaml/astro/utils";
const schema = getCollectionItemWithLocationsSchema();

Frontmatter example:

---
title: "10 Days in Japan"
pubDate: 2024-03-15
locations:
- coordinates: [139.6917, 35.6895]
name: "Tokyo"
description: "Days 1-4"
- coordinates: [135.7681, 35.0116]
name: "Kyoto"
description: "Days 5-7"
- coordinates: [135.5023, 34.6937]
name: "Osaka"
description: "Days 8-10"
---

Best for: Neighborhood guides, property coverage areas, territorial content.

import { getCollectionItemWithRegionSchema } from "@maplibre-yaml/astro/utils";
const schema = getCollectionItemWithRegionSchema();

Frontmatter example:

---
title: "Guide to Brooklyn Heights"
pubDate: 2024-03-15
region:
name: "Brooklyn Heights"
coordinates:
- [
[-73.998, 40.698],
[-73.985, 40.698],
[-73.985, 40.692],
[-73.998, 40.692],
[-73.998, 40.698],
]
fillColor: "#3388ff"
fillOpacity: 0.3
---

Best for: Hiking guides, road trips, cycling routes.

import { getCollectionItemWithRouteSchema } from "@maplibre-yaml/astro/utils";
const schema = getCollectionItemWithRouteSchema();

Frontmatter example:

---
title: "Pacific Coast Highway Road Trip"
pubDate: 2024-03-15
route:
name: "SF to LA via PCH"
coordinates:
- [-122.4194, 37.7749] # San Francisco
- [-122.0322, 36.9741] # Santa Cruz
- [-121.8947, 36.6002] # Monterey
- [-120.6596, 35.2828] # San Luis Obispo
- [-119.6982, 34.4208] # Santa Barbara
- [-118.2437, 34.0522] # Los Angeles
color: "#e74c3c"
width: 4
---

For collection items with mixed content types, use the comprehensive schema:

import { z } from "astro/zod";
import { getCollectionItemWithGeoSchema } from "@maplibre-yaml/astro/utils";
const schema = getCollectionItemWithGeoSchema({
// Add your custom fields
author: z.string(),
category: z.enum(["travel", "hiking", "food", "city-guide"]),
tags: z.array(z.string()).default([]),
});

Adding Geographic Data to Content Collections

Section titled “Adding Geographic Data to Content Collections”

You can add map support to any existing Astro content collection by importing the geographic schemas. This works for points, polygons, and lines — all optional, so items without geographic data still work. The getCollectionItemWith*Schema helpers above build a whole schema for you; when your collection already has one, add the geographic fields to it instead.

src/content.config.ts
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import { glob } from "astro/loaders";
import {
LocationPointSchema,
RegionPolygonSchema,
RouteLineSchema,
} from "@maplibre-yaml/astro/utils"; // /utils in the content config: see the caution above
const projects = defineCollection({
loader: glob({ pattern: "**/*.md", base: "./src/collections/projects" }),
schema: z.object({
title: z.string(),
status: z.string(),
summary: z.string().optional(),
// Geographic data — all optional
location: LocationPointSchema.optional(), // point
region: RegionPolygonSchema.optional(), // polygon
route: RouteLineSchema.optional(), // line
}),
});
export const collections = { projects };

The fields take the same frontmatter as in Schema Options: location as in Single Location Posts, region as in Region/Area Posts, route as in Route/Trail Posts, and locations (a list of points) as in Multiple Locations Posts.

Use buildMapConfigFromEntry in your dynamic route page ([slug].astro). The helper inspects the entry data, dispatches to the appropriate builder by geometry type, and falls back to a supplied default if no geometry is set.

src/pages/projects/[slug].astro
---
import { getCollection } from "astro:content";
import { Map, buildMapConfigFromEntry } from "@maplibre-yaml/astro";
import { globalMapConfig } from "../../lib/map-config";
export async function getStaticPaths() {
const entries = await getCollection("projects");
return entries.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
}
const { entry } = Astro.props;
const mapConfig = await buildMapConfigFromEntry(entry.data, globalMapConfig, {
label: entry.data.title,
description: entry.data.summary,
fallback: {
coordinates: globalMapConfig.defaultCenter!,
name: entry.data.title,
},
});
---
<Map config={mapConfig} height="300px" />

globalMapConfig here is the site-wide configuration loaded with loadGlobalMapConfig (Global Configuration), which supplies the basemap and the defaultCenter used as the fallback.

The helper recognizes these fields in this precedence order:

  1. feature_ref — external GeoJSON file reference
  2. region — inline polygon
  3. route — inline line
  4. locations — inline multi-point
  5. location — inline single point
  6. options.fallback — supplied default

The first set field wins; later fields are ignored. The helper is always async because feature_ref requires file I/O at build time.

To handle each geometry type yourself (custom popups or styling per type), call the builders directly instead, as the Complete Blog Template Example below does.

Creates a map centered on a single location with a marker:

import { buildPointMapConfig } from "@maplibre-yaml/astro";
const config = buildPointMapConfig({
location: {
coordinates: [-74.006, 40.7128],
name: "New York City",
description: "The Big Apple",
},
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
zoom: 12, // optional, defaults to location.zoom or 12
interactive: true, // optional, defaults to true
});

Creates a map showing multiple locations with automatic bounds fitting:

import { buildMultiPointMapConfig } from "@maplibre-yaml/astro";
const config = buildMultiPointMapConfig({
locations: [
{ coordinates: [-74.006, 40.7128], name: "NYC" },
{ coordinates: [-118.2437, 34.0522], name: "LA" },
{ coordinates: [-87.6298, 41.8781], name: "Chicago" },
],
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
});

buildMultiPointMapConfig and buildRouteMapConfig also take padding: the pixels kept between the data and the map edge when the map fits it (default 50; applied from astro 0.7.1).

Creates a map displaying a polygon region:

import { buildPolygonMapConfig } from "@maplibre-yaml/astro";
const config = buildPolygonMapConfig({
region: {
coordinates: [
[
[-74.0, 40.7],
[-73.9, 40.7],
[-73.9, 40.8],
[-74.0, 40.8],
[-74.0, 40.7], // Close the polygon
],
],
name: "Manhattan",
fillColor: "#ff6b6b",
fillOpacity: 0.4,
},
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
});

Creates a map showing a line/route with start/end markers:

import { buildRouteMapConfig } from "@maplibre-yaml/astro";
const config = buildRouteMapConfig({
route: {
coordinates: [
[-122.4194, 37.7749],
[-121.8947, 36.6002],
[-118.2437, 34.0522],
],
name: "California Road Trip",
color: "#e74c3c",
width: 4,
},
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
});

buildMultiPolygonMapConfig and buildMultiLineStringMapConfig take several polygons or lines, and buildFeatureMapConfig builds a map from a GeoJSON feature reference. buildMapConfigFromEntry picks the right builder from an entry’s fields (Dynamic map per collection item). Every builder takes a global configuration as its second argument.

Instead of specifying mapStyle on every map, create a shared configuration. For a validated, YAML-backed version that every builder accepts as its second argument, see loadGlobalMapConfig in Astro: Global Configuration. A plain object works too:

src/lib/map-config.ts
export const globalMapConfig = {
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
// Add any other defaults you want to share
};

mapStyle takes a MapLibre style URL or, for a basemap you host yourself with no API key, a Protomaps .pmtiles file in public/: { pmtiles: "data/city.pmtiles", flavor: "light" } (Host your own basemap).

Then use it in your pages:

---
import { Map, buildPointMapConfig } from '@maplibre-yaml/astro';
import { globalMapConfig } from '../lib/map-config';
const { location } = Astro.props.post.data;
const mapConfig = location
? buildPointMapConfig({
location,
mapStyle: globalMapConfig.mapStyle
})
: null;
---
{mapConfig && <Map config={mapConfig} height="300px" />}

Here’s a full example of a collection item post template with conditional map rendering:

src/pages/posts/[...slug].astro
---
import { getCollection, render } from 'astro:content';
import {
Map,
buildPointMapConfig,
buildMultiPointMapConfig,
buildPolygonMapConfig,
buildRouteMapConfig
} from '@maplibre-yaml/astro';
export async function getStaticPaths() {
const posts = await getCollection('posts');
return posts.map(post => ({
params: { slug: post.id },
props: { post }
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
const { location, locations, region, route } = post.data;
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
// Build appropriate map config based on available data
function getMapConfig() {
if (location) {
return buildPointMapConfig({ location, mapStyle: MAP_STYLE });
}
if (locations && locations.length > 0) {
return buildMultiPointMapConfig({ locations, mapStyle: MAP_STYLE });
}
if (region) {
return buildPolygonMapConfig({ region, mapStyle: MAP_STYLE });
}
if (route) {
return buildRouteMapConfig({ route, mapStyle: MAP_STYLE });
}
return null;
}
const mapConfig = getMapConfig();
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<title>{post.data.title}</title>
</head>
<body>
<article class="post">
<header>
<h1>{post.data.title}</h1>
<time datetime={post.data.pubDate.toISOString()}>
{post.data.pubDate.toLocaleDateString()}
</time>
</header>
{mapConfig && (
<div class="post-map">
<Map config={mapConfig} height="350px" />
</div>
)}
<div class="post-content">
<Content />
</div>
</article>
</body>
</html>
<style>
.post-map {
margin: 2rem 0;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
</style>

When multiple collection items share geometry from a single source-of-truth GeoJSON file, copying coordinates into each frontmatter is fragile and verbose. The feature_ref field lets a collection item point at a feature in an external GeoJSON file (matched by featureId or by property equality). At build time, the loader reads the file, finds the matching feature, detects its geometry type, and dispatches to the existing point/polygon/route builders.

  1. Add feature_ref to your collection schema

    src/content.config.ts
    import { defineCollection } from "astro:content";
    import { z } from "astro/zod";
    import { glob } from "astro/loaders";
    import {
    FeatureRefSchema,
    LocationPointSchema,
    RegionPolygonSchema,
    RouteLineSchema,
    } from "@maplibre-yaml/astro/utils";
    export const collections = {
    poas: defineCollection({
    loader: glob({ pattern: "**/*.md", base: "./src/collections/poas" }),
    schema: z.object({
    title: z.string(),
    gotf_id: z.number(),
    // Either feature_ref OR inline geometry -- not both
    feature_ref: FeatureRefSchema.optional(),
    location: LocationPointSchema.optional(),
    region: RegionPolygonSchema.optional(),
    route: RouteLineSchema.optional(),
    }),
    }),
    };

    For automatic mutual-exclusivity validation, use getCollectionItemWithFeatureRefSchema() instead. It rejects items declaring both feature_ref and inline geometry at content-collection-parse time.

  2. Reference features from frontmatter

    ---
    title: "Gowanus Rezoning Area"
    gotf_id: 1.1
    feature_ref:
    source: "./src/data/gowanus.geojson"
    featureId: "poa-1.1"
    ---
  3. Build the map in your dynamic page

    The simplest path is buildMapConfigFromEntry — it inspects the entry data, dispatches by geometry type (including feature_ref), and produces a MapBlock:

    src/pages/poas/[slug].astro
    ---
    import { getCollection } from "astro:content";
    import { Map, buildMapConfigFromEntry } from "@maplibre-yaml/astro";
    import { globalMapConfig } from "../../lib/map-config";
    export async function getStaticPaths() {
    const entries = await getCollection("poas");
    return entries.map((entry) => ({ params: { slug: entry.id }, props: { entry } }));
    }
    const { entry } = Astro.props;
    const mapConfig = await buildMapConfigFromEntry(entry.data, globalMapConfig, {
    label: entry.data.title,
    fallback: { coordinates: globalMapConfig.defaultCenter! },
    });
    ---
    <Map config={mapConfig} height="300px" />

    If you need custom per-type behavior, call the underlying builders directly (buildFeatureMapConfig, buildPolygonMapConfig, etc.) instead. See Map Builder Functions.

Two ways to identify which feature to use:

FormWhen to use
featureId: <string | number>When your GeoJSON features have stable top-level id fields (RFC 7946)
match: { property, equals }When IDs live in feature.properties (common — mirrors MapLibre’s promoteId)

Exactly one match must be returned. Zero matches or multiple matches throw GeoJSONLoadError with details (file path, total feature count, sample IDs/values).

Frontmatter can override feature.properties.name and feature.properties.description, plus all style fields (markerColor, fillColor, strokeColor, fillOpacity, color, width, zoom). Overrides take priority; feature.properties are the fallback.

TypeBuilder dispatched
PointbuildPointMapConfig
MultiPointbuildMultiPointMapConfig
LineStringbuildRouteMapConfig
MultiLineStringbuildMultiLineStringMapConfig (renders all segments)
PolygonbuildPolygonMapConfig
MultiPolygonbuildMultiPolygonMapConfig (renders all polygons)
GeometryCollection (single member)dispatches to the inner geometry type
GeometryCollection (multiple members)throws GeoJSONLoadError — split into separate features

The source value is resolved against process.cwd() by default. Two settings on FeatureLoadOptions adjust this:

import {
buildFeatureMapConfig,
type FeatureLoadOptions,
} from "@maplibre-yaml/astro";
const loadOptions: FeatureLoadOptions = {
// Use a custom project root for monorepo builds where cwd != Astro project dir.
// Canonicalized via realpath so macOS `/var` -> `/private/var` doesn't trip
// the containment check.
projectRoot: "/absolute/path/to/astro/project",
// Opt in to absolute `source` paths. Default: false.
// Required for tests using tmpdir, monorepo data outside the project root,
// or any controlled call site. Do NOT enable when frontmatter values can
// come from untrusted content (CMS-supplied entries, user uploads).
allowAbsolutePaths: true,
};
await buildFeatureMapConfig({ ref, loadOptions }, globalConfig);

The same loadOptions flows through buildMapConfigFromEntry and loadFeatureFile.

The builder throws one of two error classes:

import {
buildFeatureMapConfig,
GeoJSONLoadError,
InvalidFeatureRefError,
} from "@maplibre-yaml/astro";
try {
await buildFeatureMapConfig({ ref }, globalConfig);
} catch (err) {
if (err instanceof InvalidFeatureRefError) {
// Thrown when the ref has neither or both of `featureId` and `match`.
// Discriminable via instanceof (not wrapped by buildFeatureMapConfig).
} else if (err instanceof GeoJSONLoadError) {
// Thrown for everything else: missing file/feature, malformed JSON,
// unsupported geometry, path-traversal violation, absolute-path
// rejection, file too large, degenerate LineString, heterogeneous
// GeometryCollection. `.message` carries the actionable message;
// `.filePath` carries the resolved path; ES2022 `.cause` carries the
// underlying fs/parse error when applicable.
}
}

buildFeatureMapConfig reads from the local filesystem via process.cwd() (or the configured projectRoot). It is build-time only — invoking it in a deployed SSR adapter context throws a GeoJSONLoadError whose ENOENT message includes deployment-context hints. With output: 'server', prerender the pages that use it (see Server output).

File sizeBehavior
< 10MBFully supported, no caveats
10-50MBSupported; one-time parse cost
50-100MBBuild-time warning
> 100MBHard error directing toward PMTiles or splitting

For data this large, load it in the browser instead of at build time: a PMTiles, FlatGeobuf or GeoParquet file in public/ is read in parts as the map needs it (Large data).

Files referenced multiple times across collection items are parsed once via an mtime-aware cache. Editing the GeoJSON in astro dev invalidates the cache on next page render — no server restart needed. For files with 200+ features, a per-property index is built lazily on the second access for that property.

FeatureRef accepts every style field (markerColor, fillColor, strokeColor, fillOpacity, color, width) because the geometry type is not known when the schema parses — it’s resolved when the file loads. When a field doesn’t apply to the resolved geometry (e.g., markerColor on a Polygon ref), the builder ignores it and emits a one-time console.warn during astro dev:

feature_ref: style override(s) [markerColor, width] do not apply to Polygon geometry from "./src/data/sample.geojson"; they will be ignored.

Warnings are deduplicated per (source, geometryType, ignored-keys) so reloading the page won’t re-emit them. Restarting astro dev clears the dedupe cache.

For advanced cases (e.g., loading one file once and dispatching to many features manually):

import {
loadFeatureFile,
findFeature,
GeoJSONLoadError,
clearFeatureCache,
} from "@maplibre-yaml/astro";
const fc = await loadFeatureFile("./src/data/gowanus.geojson");
const feature = findFeature(fc, { source: "...", featureId: "poa-1.1" });
// Then dispatch manually with the existing sync builders

clearFeatureCache() is exported for test isolation between files that share absolute paths.

Calculate the center point of multiple coordinates:

import { calculateCenter } from "@maplibre-yaml/astro";
const center = calculateCenter([
[-74.006, 40.7128],
[-73.935, 40.73],
[-73.867, 40.752],
]);
// Returns approximately [-73.936, 40.732]

Calculate the bounding box of coordinates:

import { calculateBounds } from "@maplibre-yaml/astro";
const bounds = calculateBounds([
[-74.006, 40.7128],
[-73.867, 40.752],
]);
// Returns [[-74.006, 40.7128], [-73.867, 40.752]]

For a site with many maps that should be validated at build time, keep each map’s YAML file in a collection. A mistake in any file then fails astro build (and shows in astro dev) instead of appearing as an error card in the browser. For a handful of maps, YAML files in public/ loaded with <Map src> are simpler (Astro).

1. Define the collection in src/content.config.ts:

import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { getMapSchema } from "@maplibre-yaml/astro/utils";
const maps = defineCollection({
loader: glob({ pattern: "**/*.yaml", base: "./src/collections/maps" }),
// The whole map document schema: sources, layers, legend, markers,
// parameters, images, every config key. Add fields with extendSchema().
schema: getMapSchema(),
});
export const collections = { maps };

2. Add YAML files to the collection, one map document each:

src/collections/maps/headquarters.yaml
type: map
id: headquarters
config:
center: [-122.4194, 37.7749]
zoom: 15
maxZoom: 18
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
sources:
office:
type: geojson
data:
type: Feature
geometry:
type: Point
coordinates: [-122.4194, 37.7749]
properties:
name: "Headquarters"
layers:
- id: office
type: circle
source: office
legend: { label: Headquarters, color: "#3b82f6" }
paint:
circle-radius: 12
circle-color: "#3b82f6"
legend:
position: bottom-left
markers:
- at: [-122.4180, 37.7762]
color: "#e63946"
popup:
- p:
- str: "Visitor parking"

3. Show an entry with <Map>:

---
import { getEntry } from 'astro:content';
import { Map } from '@maplibre-yaml/astro';
const headquarters = await getEntry('maps', 'headquarters');
if (!headquarters) throw new Error('maps/headquarters.yaml not found');
---
<Map config={headquarters.data} height="500px" />

To render an entry with your own component around <ml-map> instead, pass JSON.stringify(entry.data) as its config attribute (see Component Patterns).

Stories work the same way with getScrollytellingSchema(): see Stories as a content collection.

For blog posts, simpler maps are often better. A single marker showing “where this happened” is more effective than a complex multi-layer visualization.

  • City overview: zoom 10-12
  • Neighborhood: zoom 13-15
  • Specific building/location: zoom 16-18

For purely illustrative maps, disable interactivity to reduce visual noise:

const config = buildPointMapConfig({
location,
mapStyle: MAP_STYLE,
interactive: false,
});