MapLibre-GL: the part of the project the README leads with
title: "MapLibre-GL: the part of the project the README leads with" slug: maplibre-as-project-anchor pillar: Proof and Case Studies angle: behind-the-scenes audience: "Mixed: prospects + technical evaluators" stack: Next.js status: draft
MapLibre-GL: the part of the project the README leads with
The README of an enterprise GIS project I shipped names four technologies in its first paragraph. MapLibre-GL is one of them. This is why that line is there, and what every decision under it inherits.
The output the reader sees from the outside
When a new engineer opens the README of an enterprise geospatial platform I worked on, the first paragraph names Next.js, MapLibre-GL, Prisma/PostgreSQL, and Redis. The order is not alphabetical. MapLibre is named second, before the database, before the cache. That placement is doing work — it is telling whoever is evaluating the codebase that the map engine is not a feature, it is the spine.
Most enterprise web projects bury their dependencies. They open with "a comprehensive platform" and walk you through admin panels and SSO before they mention a single library. This README does the opposite. It states the engine first because every architectural choice downstream — the viewer subsystem, the controller and component registries, the custom typings file at the repo root, the way layer state is patched at runtime — is a consequence of having picked MapLibre and committed to it.
This post is a walk through that commitment from the inside. Why MapLibre and not Mapbox GL JS, not Azure Maps, not Google Maps Platform. What it gives you the moment you accept it as the anchor. What it forces you to accept in return.
The decision tree behind the README's first paragraph
There are roughly four nodes in the decision tree, in the order they get resolved:
- Commercial vs open vector tile engine. Once you have decided you need vector tiles with custom styling, runtime layer mutation, and 3D extrusion, you are choosing between Mapbox GL JS v2+ (proprietary, requires a Mapbox access token, billed per session) and MapLibre GL JS (BSD-3, fork of Mapbox GL JS v1, no token, no billing). For a project that runs against private vector tile servers and bills its own enterprise customers, MapLibre wins on cost predictability alone.
- Library-level engine vs platform SDK. Azure Maps and Google Maps Platform are full platforms — basemaps, geocoding, routing, all bundled, all metered. MapLibre is just an engine. If the client owns the data (TAZ polygons, hex grids, public-transport networks, building footprints from the local cadastre), you do not need a platform. You need an engine that renders the tiles you already have.
- Coupling to the engine's data model. This is the node that most teams underestimate. Once you pick MapLibre, your application's entire spatial state is expressed in MapLibre's
StyleSpecification— sources, layers, paint and layout properties. Layer ordering, filter expressions, paint interpolation, visibility — all of it lives inside the engine. There is no good way to abstract over it without rebuilding it. So the README leads with MapLibre because the codebase cannot pretend the engine is swappable. - Custom type augmentation. MapLibre's published types are deliberately strict and deliberately narrow. They do not know about your application's metadata. If you want type-safe access to per-layer flags — filters, info-window enablement, chart enablement, group membership — you have to extend the library's types yourself. That extension lives in a file at the repo root and the IDE picks it up for every file that imports from
maplibre-gl. It is the second piece of evidence that MapLibre is not a dependency, it is part of the foundation.
The rest of this post walks each node with the actual artifact that decided it.
Walk each node with a real artifact in the repo
Node 1 — the dep line that ends the commercial-vs-open debate
"maplibre-gl": "^5.6.2",
"maplibre-gl-basemaps": "^0.1.3",
"@mapbox/mapbox-gl-draw": "^1.5.0",
"@mapbox/mbtiles": "^0.12.1",
There is a quiet detail in those four lines. maplibre-gl is the engine. @mapbox/mapbox-gl-draw is the drawing plugin from Mapbox — still BSD-3, still works against MapLibre because MapLibre v5 retains API compatibility with the parts of Mapbox GL JS v1 that the draw plugin uses. @mapbox/mbtiles is a Node-side mbtiles reader for serving tiles from the backend. The project picks pieces from the Mapbox ecosystem where they are still permissively licensed, and avoids the parts that require an access token.
maplibre-gl-basemaps is the only third-party MapLibre plugin in use, and it is doing one specific thing: a basemap selector UI. That is the entire MapLibre extension footprint. Everything else — drawing, registries, custom controllers — is hand-rolled in the repo.
Node 2 — the viewer initialization that proves the engine is the spine
import 'maplibre-gl/dist/maplibre-gl.css'
import { useEffect, useRef } from 'react'
import maplibregl, { Map } from 'maplibre-gl'
import { useViewerStore } from '@/components/viewer/store'
// ...
import { extendMapLibre } from './core/extensions/MapLibreExtensions'
extendMapLibre()
export default function MaplibreMapViewer() {
const { setGlobalMap, setGlobalControl, isDrawingMode } = useViewerStore()
const mapContainer = useRef<HTMLDivElement>(null)
const { setMap } = useMap();
// ...
useEffect(() => {
const map = new maplibregl.Map({
container: mapContainer.current!,
attributionControl: false,
center: [/* lng */, /* lat */],
zoom: 13,
pitch: 45,
bearing: -10,
style: {
version: 8,
sources: {
base_default: {
type: 'raster',
tiles: ['https://a.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}@2x.png'],
tileSize: 256,
attribution: '© OpenStreetMap Contributors',
maxzoom: 19
}
},
layers: [
{ id: 'base-raster', type: 'raster', source: 'base_default' }
],
sky: {}
},
maxZoom: 18,
maxPitch: 85
})
Three things in that snippet are load-bearing:
The map is instantiated with an inline StyleSpecification instead of a remote style.json URL. That is a deliberate choice. The repo wants control over what loads at boot — a single Carto raster basemap (light_nolabels) with no labels, because the project draws its own labels later, and because shipping a remote style would mean an extra network hop before the user sees anything. The sky: {} line enables MapLibre's atmospheric sky at the cost of basically nothing, because pitch is set to 45 degrees out of the gate and a 3D pitch with no sky looks wrong.
pitch: 45, bearing: -10, maxPitch: 85. These are not defaults. The default MapLibre pitch is 0 (top-down). Choosing 45 at boot means the project is committed to a 3D camera as the primary view. maxPitch: 85 exists because MapLibre clamps to 60 by default and the 3D extrusion analysis the README brags about needs the steeper angle.
attributionControl: false. The default MapLibre attribution box is removed, and a custom one is rendered elsewhere. That is a typical enterprise UI move — the design system controls every pixel — but it also means whoever ships this is on the hook for placing the OSM/Carto attribution somewhere visible. The compliance burden moves from the library to the team.
Node 3 — the typings file at the repo root that makes the engine extensible
import { LayerSpecification as OriginalLayerSpecification, SourceSpecification, Map as OrginalMap } from 'maplibre-gl'
import { ControllerRegistry } from './components/viewer/core/registries/ControllerRegistry';
import { ComponentRegistry } from './components/viewer/core/registries/ComponentRegistry';
import { IController, IComponent } from './components/viewer/core/types'
type VectorSourceDef = SourceSpecification & { type: 'vector'; tiles: string[] }
interface LayerFilter {
type: string
name: string
field: string
options: string[]
selected: string[]
}
declare module 'maplibre-gl' {
interface LayerSpecification extends OriginalLayerSpecification {
id: string
type: 'fill' | 'line' | 'symbol' | 'circle' | 'heatmap' | 'raster' | 'hillshade' | 'background' | 'fill-extrusion' | 'custom'
// ...
metadata?: {
isManagedLayer?: boolean
name?: string
description?: string
group?: string
infoWindowEnabled?: boolean
filters?: LayerFilter[]
chartEnabled?: boolean
/** Embedded MapLibre source spec to create if missing */
sourceDef?: VectorSourceDef | GeoJSONSourceDef
}
layout?: Record<string, any>
}
interface Map {
controllers: ControllerRegistry
components: ComponentRegistry
registerController(controller: IController): Promise<void>;
getController<T extends IController = IController>(name: string): T | undefined;
removeController(name: string): Promise<void>;
listControllers(): string[];
createComponent<T extends IComponent>(component: T): T;
removeComponent(uuid: string): void;
getComponent(uuid: string): IComponent | undefined;
setActiveComponent(component: IComponent | undefined): void;
getActiveComponent(): IComponent | undefined;
}
}
This file is the most important argument in favor of MapLibre as the anchor. It is doing two jobs at once.
First, it extends LayerSpecification.metadata with project-specific fields. isManagedLayer is the discriminator that tells the UI "this is one of ours, render it in the LayersPanel". filters carries the per-layer filter config used by the attribute table. sourceDef is an inlined source spec so a layer can be added without first registering its source — the application registers it lazily if missing. None of this is in the published maplibre-gl types. Without this file, every read of layer.metadata.filters would be any and the IDE would silently lie about the shape.
Second, it extends the Map interface itself with controllers, components, and the verbs that go with them. That is more aggressive than metadata extension — it is telling TypeScript that every maplibregl.Map instance has a controller registry and a component registry attached to it. The runtime side of that contract lives in MapLibreExtensions.ts, and the type side lives in maplibre.d.ts. The two files are a single feature in two places.
Node 4 — the runtime extension that backs the types
import maplibregl from 'maplibre-gl';
import { IController, IComponent } from '../types';
import { ControllerRegistry } from '../registries/ControllerRegistry';
import { ComponentRegistry } from '../registries/ComponentRegistry';
export function extendMapLibre(): void {
const MapProto = maplibregl.Map.prototype as any;
if (MapProto._controllersExtended) return;
Object.defineProperty(MapProto, 'controllers', {
get() {
if (!this._controllerRegistry) {
this._controllerRegistry = ControllerRegistry.getInstance();
}
return this._controllerRegistry;
},
});
Object.defineProperty(MapProto, 'components', {
get() {
if (!this._componentRegistry) {
this._componentRegistry = ComponentRegistry.getInstance();
}
return this._componentRegistry;
},
});
MapProto.registerController = async function (controller: IController) {
await this.controllers.register(controller, this);
};
// ...
MapProto._controllersExtended = true;
}
The repo monkey-patches maplibregl.Map.prototype once, at module load, with a guard flag so it cannot patch twice. Every Map instance gains a registry-based plugin system. Controllers (mouse, keyboard, pointer, memory) and components (drawings, popups, draggable panels) register themselves with the map and the map owns their lifecycle.
There is a quiet engineering reason this works. MapLibre's Map is a class, not a frozen object, and its prototype is mutable. The patch is hot in the sense that it has to run before any new maplibregl.Map(...) call, which is why extendMapLibre() is called at module top-level in MaplibreMapViewer.tsx. If you tried this with a closed-source SDK that wraps its instance behind a builder, you would not have prototype access and the whole registry pattern would have to live in a wrapper class. The fact that MapLibre is open and class-based is itself part of why it was picked.
Node 5 — patching the engine's own methods to fire custom events
if (!(map as any)._addLayerPatched) {
const originalAddLayer = map.addLayer;
map.addLayer = function (...args) {
const layer = args[0];
// Prevent duplicate add
if (this.getLayer(layer.id)) {
console.warn(`Layer "${layer.id}" already exists, skipping addLayer.`);
return this;
}
// Fire custom event once per unique add
this.fire('layer-added', { layer });
return originalAddLayer.apply(this, args);
};
(map as any)._addLayerPatched = true;
}
const originalRemoveLayer = map.removeLayer
map.removeLayer = function (...args) {
this.fire('layer-removed', { layerId: args[0] })
return originalRemoveLayer.apply(this, args)
}
map.addLayer and map.removeLayer are re-bound on the instance. The originals are kept and called through. The wrappers fire MapLibre events (layer-added, layer-removed) that the React LayersPanel listens to. This is how the UI stays in sync with engine state without polling.
The duplicate-add guard exists because the LayersPanel is rendered inside a Next.js App Router tree with React 19 strict mode. Components mount twice in development. Without this guard, every layer would be added twice on first paint, and MapLibre would throw on the duplicate ID. The fix lives on the engine, not on the React side, because there are multiple call sites that add layers and they all benefit.
Node 6 — the layer state classifier that depends on metadata
import type { LayerSpecification } from 'maplibre-gl'
import maplibregl from 'maplibre-gl'
export const isImportedLayer = (layer: LayerSpecification, globalMap?: maplibregl.Map) => {
try {
const srcId: any = (layer as any).source
const src: any = globalMap?.getSource?.(srcId)
const isGeoJson = src?.type === 'geojson'
const metaImported = (layer as any)?.metadata?.category === 'imported'
|| (layer as any)?.metadata?.isImportedLayer === true
const isAnalysis = (layer as any)?.metadata?.category === 'analysis'
return Boolean((isGeoJson || metaImported) && !isAnalysis)
} catch {
return false
}
}
export const isAnalysisLayer = (layer: LayerSpecification) => {
try {
return (layer as any)?.metadata?.category === 'analysis'
} catch {
return false
}
}
export const isExtrusionCompatible = (layer: LayerSpecification) => {
try {
return layer.type === 'fill'
} catch {
return false
}
}
These three predicates are how the LayersPanel decides which section to render a layer in — imported user uploads, analysis results, base layers, other. Every predicate reads layer.metadata. Every read leans on the typings file at the repo root. Strip the typings file and the predicates still work at runtime but you lose every guarantee in the IDE.
This is the part that does not show up in the README but defines the day-to-day developer experience. Adding a new layer kind is a three-step move: declare the metadata shape in maplibre.d.ts, write a predicate in utils.ts, render a section in the panel. The engine's data model carries the feature.
The thing that almost went differently
The repo's package.json also lists @googlemaps/js-api-loader, @googlemaps/three, azure-maps-control, geo-three, and react-three-map. That is not noise. There was a phase where the project hedged on the engine — kept Azure Maps installed because the client had Azure credentials and there was a real argument for using Azure Maps Indoor for the building floors module, kept Google Maps installed because the routing service was easier to call against Google's API, kept geo-three for a hypothetical Three.js-backed 3D earth view.
What killed the hedge was the metadata extension. The moment the typings file extended LayerSpecification.metadata and the LayersPanel started reading those fields, every other engine became a port-or-die proposition. You cannot run the same panel code against Azure Maps because Azure Maps does not expose LayerSpecification the same way. You cannot run it against Google Maps Platform because Google Maps does not have a public style spec at all.
So the dependencies stayed in package.json as fossils from the hedge, the actual viewer collapsed onto MapLibre, and the README was rewritten to lead with the engine. The lock-in was already there in the typings file — the README just made it visible.
What you would change if starting over
Two things.
First, I would put maplibre.d.ts somewhere less load-bearing. Having it at the repo root works because tsconfig.json includes everything, but it makes the file easy to delete by accident and easy to forget about when refactoring. A types/maplibre-augment.d.ts with a comment block at the top would survive future refactors better. There is already a maplibre.d.ts.back in the repo, which tells me this file has been broken before and recovered from a backup. That is a flag.
Second, I would write the patching of map.addLayer and map.removeLayer as a single named extension function alongside extendMapLibre(), instead of inlining it in the React effect. Right now extendMapLibre() patches the prototype once at module load (clean), and MaplibreMapViewer.tsx patches the specific map instance once at mount (also clean, but in the wrong place). The instance-level patches should live next to the prototype-level patches so the engine extension surface is one concept in one file.
Neither of these is a bug. They are organization debts that compound when a new engineer joins.
Trade-off
Picking MapLibre as the anchor buys you cost predictability, prototype-level extensibility, and a permissive license. It costs you everything a managed platform would have given you — geocoding, routing, search, address autocomplete, traffic, indoor maps. The project ships its own data layer for the parts of the platform stack it actually needs. That is the right move for an enterprise GIS application with private vector tiles. It would be the wrong move for a consumer storefront that needs a "find nearest store" feature on day one.
The other trade-off is the type augmentation. Once you have extended maplibre-gl with a project-specific metadata shape, you cannot upgrade MapLibre majors blindly. A maplibre-gl 6.x release that renames LayerSpecification or restructures metadata would force a coordinated change across the typings file, every predicate, and the LayersPanel. The repo accepts this in exchange for type safety. Plan the upgrades.
Business impact
For a team evaluating this codebase as the basis of a future contract, the README's first paragraph is doing more than describing the stack. It is signaling that the engine is not interchangeable, the customizations are deep, and any future work will be cheaper if it is written against MapLibre directly than if it tries to wrap the engine in an abstraction layer. That signal is worth pricing in. Estimates that assume "we can swap MapLibre for Cesium later" should be treated as red flags during scoping.
For a team evaluating this kind of project for their own roadmap — vector tiles, 3D extrusion, per-layer analytics — the takeaway is to commit to an engine before you commit to features. The features are easy. The engine is forever.
What to do next
Open your own project's README. Look at the first paragraph. If it lists five technologies and they are roughly interchangeable in your codebase, you have a marketing document. If one of them is named first because the rest of the architecture is shaped around it, you have a real anchor — and the rest of the README has to defend that placement.
If you want to read the matching custom-type-augmentation pattern, find a *.d.ts file in your repo root that does declare module 'some-library'. If it does not exist, you have not committed to the library yet. You are still renting it.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox