Adding an Interactive Travel Map to Hugo Blog (Leaflet + JSON)

Posted by Wayne X.Y. on Tuesday, March 10, 2026

Adding an Interactive Travel Map to Hugo Blog (Leaflet + JSON)

Recently, I wanted to add an interactive map to the top of my blog’s Travel page to mark the places I’ve visited. After some consideration, I decided to use the open-source and lightweight Leaflet.js, paired with a custom JSON file to manage the locations and marker colors.


🗺️ Implementation Goals

  1. Pin a map to the top of /life/travel to display the countries and places I’ve visited.
  2. Centrally manage location data (including English and Chinese names, latitude, longitude, and custom colors) via a JSON file.
  3. Use Leaflet.js to render the map, and automatically calculate the zoom boundaries (fitBounds) based on the JSON data to perfectly fit all markers.
  4. Encapsulate the map into a Hugo Shortcode
    to easily reuse it across different language pages.

💾 Creating the Data File (Data Layer)

First, create a travel.json file under the data/ directory of your Hugo project. If you have new trips in the future, you just need to update this file without touching any code:

[
  {
    "name_zh": "台北 (台灣)",
    "name_en": "Taipei (Taiwan)",
    "lat": 25.033,
    "lng": 121.5654,
    "color": "red"
  },
  {
    "name_zh": "福岡市",
    "name_en": "Fukuoka City",
    "lat": 33.5902,
    "lng": 130.4017,
    "color": "red"
  }
]

🧩 Encapsulating the Hugo Shortcode (Presentation Layer)

Next, add travel-map.html under layouts/shortcodes/. We fetch Leaflet’s CSS and JS via CDN, and read the JSON data we just created.

<link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" crossorigin="" />
<script src="https://unpkg.com/[email protected]/dist/leaflet.js" crossorigin=""></script>

<div id="travel-map" style="height: 400px; width: 100%; border-radius: 8px; margin-bottom: 2rem; z-index: 1;"></div>

<script>
    document.addEventListener("DOMContentLoaded", function () {
        // Fix Leaflet's default icon path issues when using CDN
        delete L.Icon.Default.prototype._getIconUrl;
        L.Icon.Default.mergeOptions({
            iconRetinaUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon-2x.png',
            iconUrl: 'https://unpkg.com/[email protected]/dist/images/marker-icon.png',
            shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
        });

        // Read data from Hugo's data/travel.json
        var travelData = \{\{ site.Data.travel | jsonify | safeJS \}\};
        var lang = "\{\{ .Page.Lang \}\}";

        var map = L.map('travel-map', {
            scrollWheelZoom: true // Enable scrolling to zoom in/out
        });

        L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
            attribution: '&copy; OpenStreetMap contributors'
        }).addTo(map);

        var bounds = [];

        if (travelData && travelData.length > 0) {
            travelData.forEach(function (loc) {
                var name = lang === "en" ? loc.name_en : loc.name_zh;
                var markerOptions = {};
                
                // Apply different marker colors based on the color property
                if (loc.color) {
                    markerOptions.icon = new L.Icon({
                        iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-' + loc.color + '.png',
                        shadowUrl: 'https://unpkg.com/[email protected]/dist/images/marker-shadow.png',
                        iconSize: [25, 41],
                        iconAnchor: [12, 41],
                        popupAnchor: [1, -34],
                        shadowSize: [41, 41]
                    });
                }
                
                var marker = L.marker([loc.lat, loc.lng], markerOptions).addTo(map)
                    .bindPopup("<b>" + name + "</b>");
                bounds.push([loc.lat, loc.lng]);
            });
            // Automatically fit bounds and set a 50px padding
            map.fitBounds(bounds, { padding: [50, 50] });
        } else {
            map.setView([23.6978, 120.9605], 5); // Default view
        }
    });
</script>

💡 Note: I specifically used leaflet-color-markers to replace the default blue marker. This allows us to assign a custom colored icon to different trips!

📄 Rendering on the Page

After completing the Shortcode, all you need to do is add this single line in any Markdown file:

\{\{< travel-map >\}\}

It not only automatically generates the interactive map, but also supports both English and Chinese natively, letting English pages automatically display English location names in the popups!

🚀 What’s Next?

Currently, marking visited locations requires manually modifying travel.json. However, when we take photos with our phones, the location info is often recorded in the photo data. Maybe in the future, we can map locations out directly based on photo data!