MapLibre GL
MapLibre GL JS is the default
engine behind <x-wirekit::. It renders vector tiles,
so the map is fully styleable — you control the colors, fonts, and layers through
a style document rather than baked-in raster images. The engine is open source
under the BSD-3-Clause license and never enters WireKit's bundle; you load it
as a peer dependency.
This guide covers the MapLibre engine specifically. For the marker model, the accessible list, the props, and Livewire wiring — all of which are engine-independent — see the Map overview.
Install
Do not install anything older. Every release up to and including 6.4.0 is affected by a critical advisory, GHSA-jrc7-96c5-q579 — a way past the sanitizer behind Popup.setHTML() — and no 5.x release carries the fix. WireKit escapes every marker string before it reaches the engine, but your own map code may call the same API.
MapLibre GL 6 is published as an ES module only, and two things follow from that. Every setup below handles both:
- It sets no global. Importing it does not create
window., so the engine has to be handed to WireKit: assigned tomaplibregl window., or passed tomaplibregl registerwhen your bundle imports WireKit itself.Map Engine() - It starts a worker from a file beside itself,
maplibre-gl-worker.. A bundler cannot see that file, so it has to be told to emit it. Otherwise the worker is requested from a path the build never wrote, and the map stays blank with no error in the console — only a failed request in the network tab.mjs
With Vite
# 1. Install the engine — a peer dependency WireKit never bundles.
npm install maplibre-gl@^6.4.1
Create a route entry that loads the engine, its worker and its stylesheet, and hands the engine to WireKit:
// resources/js/maps.js
// 1. The engine, as a module namespace.
import * as maplibregl from 'maplibre-gl';
// 2. Its worker. `?worker&url` makes Vite bundle the worker with everything it imports
// and return the URL it wrote it to. Leave this out and the map never draws.
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
// 3. The engine's stylesheet — controls, popups, attribution.
import 'maplibre-gl/dist/maplibre-gl.css';
// 4. Point MapLibre at the worker Vite emitted.
maplibregl.setWorkerUrl(workerUrl);
// 5. Hand the engine to WireKit — the map glue behind @wirekitScripts reads it here.
window.maplibregl = maplibregl;
Add the entry to the input list of the laravel() plugin in vite.config.js:
// 1. Beside your app entry. It builds as a file of its own, so only the pages that
// render a map download the engine.
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/maps.js'],
Then load it on the pages that render a map, before @wirekitScripts:
{{-- 1. The map entry first. Both tags are deferred, and the browser runs deferred
scripts in document order — so the engine is in place when WireKit's map glue
starts. --}}
@vite(['resources/js/maps.js'])
@wirekitScripts
provider="maplibre" is the default, so once the engine is present the map upgrades from the marker-list fallback to a live MapLibre canvas — no prop changes needed.
If WireKit reaches your bundle as wirekit.esm.js rather than through @wirekitScripts, pass the engine in directly instead of assigning a global. A map that rendered before the engine was registered shows its marker list until then, and boots the moment it is.
// resources/js/maps.js — when your bundle imports WireKit's ES module build
// 1. The engine, its worker and its stylesheet, exactly as above.
import * as maplibregl from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url';
import 'maplibre-gl/dist/maplibre-gl.css';
// 2. WireKit's registration function, from the build your app entry imports.
import { registerMapEngine } from '../../vendor/pushery/wirekit/dist/wirekit.esm.js';
maplibregl.setWorkerUrl(workerUrl);
// 3. Hand the engine over — no global involved.
registerMapEngine('maplibre', maplibregl);
Without a bundler
Serve the engine's dist/ folder from your own origin. Its files import each other by relative path and the worker starts from the same folder, so copy the whole folder rather than picking files out of it:
# 1. Install the engine.
npm install maplibre-gl@^6.4.1
# 2. Copy its build into your public folder — and again after every upgrade.
mkdir -p public/vendor/maplibre && cp -R node_modules/maplibre-gl/dist/. public/vendor/maplibre/
{{-- 1. The engine's stylesheet. --}}
<link rel="stylesheet" href="/vendor/maplibre/maplibre-gl.css">
{{-- 2. The engine as a module, handed to WireKit. A module script is deferred, so it
runs before @wirekitScripts as long as it comes first. --}}
<script type="module">
import * as maplibregl from '/vendor/maplibre/maplibre-gl.mjs';
window.maplibregl = maplibregl;
</script>
@wirekitScripts
A browser runs a module script only when the response carries a JavaScript MIME type, and whether your server knows the .mjs extension is decided by its own MIME table, not by WireKit. A server that does not know it answers application/: the import fails, the map finds no engine, and only the marker list remains. It can work on your machine and break after a deploy, because a development server and a production server keep different tables.
Check the deployed file, not the local one:
# 1. Ask the production server which type it sends for the engine.
curl -sI https://example.com/vendor/maplibre/maplibre-gl.mjs | grep -i '^content-type'
# 2. It must print text/javascript or application/javascript. Anything else is the failure above.
When the type is wrong, the browser console names it. The first line is Chromium's wording, the second WebKit's:
Failed to load module script: Expected a JavaScript-or-Wasm module script but the server responded with a MIME type of "application/octet-stream".
'application/octet-stream' is not a valid JavaScript MIME type for module script
Register .mjs as a JavaScript type in your web server's configuration, or load the engine from a CDN as the next section shows — a CDN sets the header itself.
From a CDN
A CDN is fine for a prototype, with one thing to get right: pin the exact version — 6.4.1 or newer, the one in your package.json. An unpinned https://unpkg. URL is not a shortcut, it is a subscription: it serves whatever is newest at the moment of the request, so the next major release reaches your users without anyone deciding that it should, on a page you have not tested it against.
{{-- 1. The stylesheet, pinned, with an integrity hash beside the pin. The pin fixes WHICH
build the CDN serves; the hash is what checks the bytes are that build, and
`crossorigin="anonymous"` is required for the browser to check it at all. unpkg publishes
the hash for any file — append `?meta` to the URL. Note this covers the stylesheet FULLY,
unlike the module below: a stylesheet imports nothing. --}}
<link href="https://unpkg.com/[email protected]/dist/maplibre-gl.css" rel="stylesheet"
integrity="sha384-REPLACE-WITH-THE-HASH-FOR-YOUR-VERSION" crossorigin="anonymous" />
{{-- 2. The engine as a module, pinned to the same version, handed to WireKit. --}}
<script type="module">
import * as maplibregl from 'https://unpkg.com/[email protected]/dist/maplibre-gl.mjs';
window.maplibregl = maplibregl;
</script>
@wirekitScripts
Two things work differently from a classic script tag:
- An
integrityhash covers less. It checks only the file it sits on, and that file importsmaplibre-gl-shared., which the worker imports again — neither of those fetches is checked. If a third party executing script in your origin is part of your threat model, and this engine is by far the largest single piece of it, serve the engine yourself.mjs - The worker starts from a
blob:URL. A browser cannot start a worker from another origin, so MapLibre starts one from ablob:URL that imports the worker file from the CDN — and your Content-Security-Policy has to allow both, as the table below shows.
Content-Security-Policy
| How the engine is loaded | worker-src needs |
Also needed |
|---|---|---|
| With Vite, or from your own origin | 'self' |
— |
| From a CDN | blob: and the CDN's origin, e.g. blob: https://unpkg.com |
the CDN's origin in script-src and style-src |
With blob: alone, the worker starts and then cannot load its own code, and the map never finishes drawing. Whatever the route, the hosts that serve your style document and tiles belong in connect-src.
Bundle size
MapLibre GL is a large library — the full engine is several times the size of a raster alternative like Leaflet, and its own documentation carries the current figure. It
is your app's dependency, not WireKit's — WireKit never bundles it — so where
you load it is your call. Loading it globally (a script in every layout, or an
import in your shared app.js) puts that weight on every page, even pages with no
map. Load it only on the routes that actually render a map instead.
The simplest approach is a per-page asset stack — the layout exposes a stack, and only your map pages push the engine into it:
{{-- Layout (resources/views/layouts/app.blade.php) --}}
{{-- 1. Expose a head stack BEFORE @wirekitScripts so a page can put the engine in
place ahead of WireKit's map glue. Non-map pages push nothing, so they never
load it. --}}
@stack('head')
@wirekitScripts
{{-- A page that renders a map --}}
{{-- 2. Push the map entry from this page only — it reaches the layout's head stack,
so the engine ships on map routes rather than on every page. --}}
@push('head')
@vite(['resources/js/maps.js'])
@endpush
<x-wirekit::map :center="[51.5, -0.12]" :zoom="11" :markers="$stops" />
Loading the engine later still — a dynamic import('maplibre-gl') once the page is up — works through register: a map that rendered first shows its accessible marker list, and boots the moment the engine is registered. If no engine ever arrives, the map says so once in the console, a few seconds after the page has loaded. With @wirekitScripts and the global, a map also picks up an engine that lands after it has rendered, because it keeps looking for one on window. That covers a page reached through wire:navigate, which can run the scripts it brings after Alpine has started it. On a slow connection the console hint can come first, and the map still draws when the engine arrives.
Map Styles
MapLibre reads a style document (a JSON describing sources + layers). Point
styleUrl at one:
{{-- A vector style from any provider. Many require an API key in the URL. --}}
<x-wirekit::map
:center="[52.5200, 13.4050]"
:zoom="12"
style-url="https://tiles.example.com/streets/style.json?key=YOUR_KEY"
:markers="$stores"
/>
With no styleUrl, the map falls back to MapLibre's keyless demo tiles
(demotiles.). Those are built from Natural Earth data for
demonstration only — great for a country-level overview, but they carry no
street-level detail. For real street maps, supply a styleUrl from a tile
provider (keyless options exist; most commercial providers issue an API key).
Dark Mode
Dark mode is a different tile style, not a CSS filter over the canvas — a
filter would wash out labels and markers. Pick the style on the server, from
whatever your application already knows about the reader's theme, and pass it as
the styleUrl prop:
{{-- $dark is your application's own theme flag — a session value, a user
preference column, whatever decides the `.dark` class on <html>. --}}
<x-wirekit::map
:center="[52.5200, 13.4050]"
:zoom="11"
:style-url="$dark
? 'https://tiles.example.com/dark/style.json?key=YOUR_KEY'
: 'https://tiles.example.com/light/style.json?key=YOUR_KEY'"
/>
styleUrl is read once, when the map boots, and there is no client-side
attribute behind it: the value travels inside the component's Alpine payload,
which the server writes at render time. So x-bind:style-url does not switch the
style — it sets an HTML attribute nothing reads, styleUrl never reaches the
payload at all, and the map quietly falls back to the keyless demo tiles above.
A live theme toggle therefore has to re-create the map rather than re-style
it. Under Livewire, give the tag a wire:key that carries the theme, so a change
replaces the element instead of patching it and the map boots against the new
style:
<x-wirekit::map
wire:key="store-map-{{ $dark ? 'dark' : 'light' }}"
:center="[52.5200, 13.4050]"
:zoom="11"
:style-url="$dark ? $darkStyleUrl : $lightStyleUrl"
/>
Store Locator
A multi-marker map is the classic store-locator surface: every location is a themed marker on the canvas and a focusable row in the list. The list is what a screen-reader user (or anyone) navigates; the map is the visual layer on top.
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Color-Coded Markers
A marker's intent colors its list dot — success / warning / danger /
accent — so a status map (fleet tracking, service coverage, an incident board)
reads at a glance. The label always carries the status in words too, so the
meaning never rides on the dot color alone.
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Single Location
For a "where to find us" map, a single marker plus a tight center/zoom does
the job. The label carries the full address so the location is reachable from the
list without reading the map. (Supply a styleUrl for street-level tiles — the
keyless demo tiles shown here stop at a regional overview.)
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Map Only
Need just the map? Pass list="false" to drop the sidebar — the canvas fills the
width and the marker list stays sr-only for assistive tech (the prop is on the
Map overview).
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Locations ()
- No locations
Selection Highlight
Selecting a store — by clicking its list row or its map pin — highlights the
matching row. Two styles via highlight: ring (the default — an inset
frame) or fill (a soft tinted-tile background). Either way highlight-color
(accent / success / warning / danger / neutral) sets the color so the
selection reads in your intent color rather than the near-black stock accent.
Click a row in each map below to compare.
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Pin Tooltips
Hovering (or tapping) a pin opens a bubble whose content follows the marker's data shape — four variants, no extra configuration:
- Text only — just a
label: a single bold line. - Styled text —
label+body: a small card with the name on top and a muted detail line (address, opening hours) below. - Text with photo — add an
imageURL: the photo renders as a card above the text, like showing the storefront at its location. - Photo only —
image+tooltip: 'image': the bubble is just the photo; the label still names the pin for screen readers and stays in the marker list.
Sanitize image URLs you don't control — the adapter escapes the src.
Interactive map needs a map library — the locations are listed alongside.
Map tiles are not loaded because your browser asked for reduced data. The locations are listed alongside.
Tiles, Attribution & Cost
The MapLibre engine is free (BSD-3-Clause), but the tiles it renders are a separate concern with their own terms:
- Demo tiles (
demotiles.) — keyless, Natural Earth data, intended for demos and testing only. Not for production.maplibre. org - Commercial providers — issue an API key and bill by map load or tile request. They handle attribution requirements in their style documents.
- Self-hosted tiles — generate your own vector tiles (e.g. from OpenStreetMap data) and serve them yourself; you own the cost and the attribution.
Whatever the source, display its required attribution visibly on the map, and read its usage policy before shipping at scale.
License
MapLibre GL JS is distributed under the BSD-3-Clause license (a permissive open-source license). WireKit treats it as an optional peer dependency — you install and load it; WireKit never bundles or relicenses it.
See Also
- Map overview — the engine-independent API, markers, list, and Livewire wiring
- MapLibre GL JS documentation
- MapLibre styles & sources spec