Skip to content
Map Position in the URL

Save the Map Position in the URL in Maptoolkit Maps JS

A map that always opens in the same place cannot be linked to. Writing the camera into the URL fragment makes every view shareable, survives a reload, and gives the browser back button something sensible to do. This example keeps centre, zoom, pitch and bearing in the hash and restores them on load.

const API_KEY = 'YOUR_API_KEY';

    const DEFAULTS = { center: [11.39085, 47.27574], zoom: 12, pitch: 0, bearing: 0 };

    // #zoom/lat/lng/bearing/pitch, the de facto convention across map sites.
    function readHash() {
        const parts = window.location.hash.replace(/^#/, '').split('/');
        if (parts.length < 3) return DEFAULTS;

        const [zoom, lat, lng, bearing, pitch] = parts.map(Number);
        if ([zoom, lat, lng].some(Number.isNaN)) return DEFAULTS;
        if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return DEFAULTS;

        return {
            center: [lng, lat],
            zoom,
            bearing: Number.isFinite(bearing) ? bearing : 0,
            pitch: Number.isFinite(pitch) ? pitch : 0
        };
    }

    const initial = readHash();

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: initial.center,
        zoom: initial.zoom,
        bearing: initial.bearing,
        pitch: initial.pitch,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl({ visualizePitch: true }), 'top-right');

    function writeHash() {
        const c = map.getCenter();
        // Precision matters: 5 decimals is about a metre, and more just makes the link ugly.
        const decimals = Math.max(0, Math.ceil(Math.log10(map.getZoom() * 4)));
        const hash = [
            map.getZoom().toFixed(2),
            c.lat.toFixed(decimals),
            c.lng.toFixed(decimals),
            Math.round(map.getBearing()),
            Math.round(map.getPitch())
        ].join('/');

        // replaceState, not location.hash: writing the hash directly pushes a history entry
        // for every frame of a pan, which buries the page the user came from.
        window.history.replaceState(null, '', `#${hash}`);
        document.getElementById('readout').textContent = `#${hash}`;
    }

    map.on('moveend', writeHash);
    map.on('load', writeHash);

    // Someone edited the address bar or used the back button.
    window.addEventListener('hashchange', () => {
        const view = readHash();
        map.jumpTo(view);
    });
<!DOCTYPE html>
<html lang="en">
<head>
    <title>Map Position in the URL - Maptoolkit Maps JS</title>
    <meta property="og:description" content="Keep the camera in the URL so a view can be shared." />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
    <link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
    <style>
        html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
        #map { width: 100%; height: 100%; }
        #readout {
            position: absolute; top: 10px; left: 10px; z-index: 999;
            background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
            font: 12px/1.5 ui-monospace, monospace; padding: 8px 12px;
        }
    </style>
</head>
<body>
<div id="map"></div>
<div id="readout"></div>
<script>
    const API_KEY = 'YOUR_API_KEY';

    const DEFAULTS = { center: [11.39085, 47.27574], zoom: 12, pitch: 0, bearing: 0 };

    // #zoom/lat/lng/bearing/pitch, the de facto convention across map sites.
    function readHash() {
        const parts = window.location.hash.replace(/^#/, '').split('/');
        if (parts.length < 3) return DEFAULTS;

        const [zoom, lat, lng, bearing, pitch] = parts.map(Number);
        if ([zoom, lat, lng].some(Number.isNaN)) return DEFAULTS;
        if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return DEFAULTS;

        return {
            center: [lng, lat],
            zoom,
            bearing: Number.isFinite(bearing) ? bearing : 0,
            pitch: Number.isFinite(pitch) ? pitch : 0
        };
    }

    const initial = readHash();

    const map = new maptoolkit.Map({
        container: 'map',
        apiKey: API_KEY,
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: initial.center,
        zoom: initial.zoom,
        bearing: initial.bearing,
        pitch: initial.pitch,
        attributionControl: { compact: false }
    });

    map.addControl(new maptoolkit.NavigationControl({ visualizePitch: true }), 'top-right');

    function writeHash() {
        const c = map.getCenter();
        // Precision matters: 5 decimals is about a metre, and more just makes the link ugly.
        const decimals = Math.max(0, Math.ceil(Math.log10(map.getZoom() * 4)));
        const hash = [
            map.getZoom().toFixed(2),
            c.lat.toFixed(decimals),
            c.lng.toFixed(decimals),
            Math.round(map.getBearing()),
            Math.round(map.getPitch())
        ].join('/');

        // replaceState, not location.hash: writing the hash directly pushes a history entry
        // for every frame of a pan, which buries the page the user came from.
        window.history.replaceState(null, '', `#${hash}`);
        document.getElementById('readout').textContent = `#${hash}`;
    }

    map.on('moveend', writeHash);
    map.on('load', writeHash);

    // Someone edited the address bar or used the back button.
    window.addEventListener('hashchange', () => {
        const view = readHash();
        map.jumpTo(view);
    });
</script>
</body>
</html>

Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.

Use the Maptoolkit Connector. Create an interactive map that writes its centre, zoom, pitch and bearing into the URL hash as the user moves, and restores that view on load.

How it works

history.replaceState rather than window.location.hash = … is the detail that matters. Assigning to location.hash pushes a new history entry, and a single pan fires moveend often enough that the back button then walks through dozens of near-identical views instead of returning to the previous page. replaceState updates the address bar in place.

The hash format is #zoom/lat/lng/bearing/pitch. That ordering is the convention MapLibre, Mapbox and OpenStreetMap all use, so links stay recognisable and are often interchangeable between tools. Note that it is lat before lng, the opposite of the [lng, lat] the map API takes, which is why readHash swaps them when building center.

readHash validates rather than trusting. A hash is user-editable, so a malformed or out-of-range value has to fall back to the default instead of putting the map at NaN, which renders a blank grey canvas with no error.

Coordinate precision is tied to zoom. At zoom 2 the extra decimals are noise; at zoom 18 they are the difference between two buildings. Fixing the precision at five everywhere works, but scaling it keeps low-zoom links short and readable.

moveend fires once the camera settles rather than on every frame, which is the right granularity for the URL. Binding to move would write hundreds of times per pan for no gain.

The hashchange listener uses jumpTo rather than flyTo. Someone pasting a link or pressing back expects to arrive, not to be flown across the country.

Next steps

The camera is rarely the only state worth sharing. A selected feature, an active filter or a chosen style belong in the same link, which usually means moving from a slash-separated hash to URLSearchParams once there is more than one thing to encode.

With state in the URL, a share button is navigator.clipboard.writeText(location.href), and a static map image built from the same centre and zoom gives the link a preview when it is pasted into a chat or an email.