November 25, 202512 min

Personal Travel Journal with Pins and Path Replay using Nuxt 4 + TypeScript + Mapbox

Build a geo-powered travel journal with tappable pins, offline-safe storage, and animated route replay using Nuxt 4, TypeScript, and Mapbox GL JS v3’s new line-trim-offset API.

Personal Travel Journal with Pins and Path Replay using Nuxt 4 + TypeScript + Mapbox

nuxt-mapbox-travel-journal

Build a personal travel journal with tappable pins, notes, and an animated “path replay” of your trips using Nuxt 4, TypeScript, and Mapbox GL JS v3. We’ll lean on two recent Mapbox capabilities: the Standard style and the line-trim-offset property for smooth route animations, plus globe projection for a slick world view.

Tags: Nuxt 4, Mapbox GL JS v3, TypeScript

Time to read: 12 min

What’s new we’ll use

  • Mapbox GL JS v3 Standard style: mapbox://styles/mapbox/standard (modern cartography, dynamic lighting, 3D)
  • Line animation via line-trim-offset (v3+): Animate line growth without expressions or hacks
  • Globe projection: A modern, beautiful world view you can toggle on

Prerequisites

  • Node.js v18+ (or v20+ recommended)
  • A Mapbox account and Access Token: https://account.mapbox.com/
  • Basic knowledge of Nuxt, Vue, and TypeScript

1) Create a fresh Nuxt 4 app

npx nuxi@latest init nuxt4-travel-journal
cd nuxt4-travel-journal
npm install

Run it once to verify:

npm run dev

Open http://localhost:3000

2) Install dependencies

We’ll use Mapbox GL JS v3 and VueUse for ergonomic localStorage.

npm install mapbox-gl@^3.5.0 @vueuse/nuxt

Why v3.5.0+? It includes the Standard style and the line-trim-offset API we’ll use for route replay.

3) Add your Mapbox token

Create an .env file in the project root:

# .env
NUXT_PUBLIC_MAPBOX_TOKEN=pk.YOUR_MAPBOX_TOKEN_HERE

Never commit real tokens to public repos unless they’re restricted.

4) Configure Nuxt

Enable VueUse, add Mapbox’s CSS, and expose the token via runtimeConfig.

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@vueuse/nuxt'],
  css: ['mapbox-gl/dist/mapbox-gl.css'],
  runtimeConfig: {
    public: {
      mapboxToken: process.env.NUXT_PUBLIC_MAPBOX_TOKEN || ''
    }
  },
  typescript: {
    strict: true
  },
  devtools: { enabled: true }
});

5) Mapbox client plugin (set the token once)

// plugins/mapbox.client.ts
import mapboxgl from 'mapbox-gl';

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig();
  mapboxgl.accessToken = config.public.mapboxToken;
});

6) Types for pins

We’ll keep core metadata: id, title, notes, date, and coordinates.

// types/travel.ts
export type LngLat = { lng: number; lat: number };

export interface Pin {
  id: string;
  title: string;
  notes?: string;
  date: string; // ISO date string (YYYY-MM-DD)
  coords: LngLat;
}

7) Travel journal composable (local-first storage)

We’ll store pins in localStorage using @vueuse, with helpers to add/remove and build a LineString for the map.

// composables/useTravelJournal.ts
import { ref, computed } from 'vue';
import { useLocalStorage } from '@vueuse/core';
import type { Pin } from '~/types/travel';

type LineStringFeature = {
  type: 'Feature';
  geometry: {
    type: 'LineString';
    coordinates: [number, number][];
  };
  properties?: Record<string, unknown>;
};

function safeUUID() {
  // Works in modern browsers; fallback if not available
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  const anyCrypto: any = globalThis.crypto as any;
  if (anyCrypto?.randomUUID) return anyCrypto.randomUUID();
  return 'xxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    const r = (Math.random() * 16) | 0; // not cryptographically secure
    const v = c === 'x' ? r : (r & 0x3) | 0x8;
    return v.toString(16);
  });
}

export function useTravelJournal() {
  const pins = useLocalStorage<Pin[]>('travel:pins', []);

  const sortedPins = computed(() =>
    [...pins.value].sort((a, b) => a.date.localeCompare(b.date))
  );

  function addPin(partial: Omit<Pin, 'id'> & { id?: string }) {
    const pin: Pin = {
      id: partial.id ?? safeUUID(),
      title: partial.title,
      notes: partial.notes,
      date: partial.date,
      coords: partial.coords
    };
    pins.value.push(pin);
  }

  function removePin(id: string) {
    pins.value = pins.value.filter(p => p.id !== id);
  }

  function clearPins() {
    pins.value = [];
  }

  function toLineString(): LineStringFeature | null {
    const coords = sortedPins.value.map(p => [p.coords.lng, p.coords.lat] as [number, number]);
    if (coords.length < 2) return null;
    return {
      type: 'Feature',
      geometry: {
        type: 'LineString',
        coordinates: coords
      },
      properties: {}
    };
  }

  function exportJSON(): string {
    return JSON.stringify(pins.value, null, 2);
  }

  function importJSON(json: string) {
    try {
      const parsed = JSON.parse(json);
      if (!Array.isArray(parsed)) throw new Error('Invalid JSON: expected an array');
      const valid = parsed.every((p: any) =>
        p &&
        typeof p.id === 'string' &&
        typeof p.title === 'string' &&
        typeof p.date === 'string' &&
        p.coords &&
        typeof p.coords.lng === 'number' &&
        typeof p.coords.lat === 'number'
      );
      if (!valid) throw new Error('Invalid pin structure');
      pins.value = parsed as Pin[];
    } catch (e) {
      console.error(e);
      throw e;
    }
  }

  return {
    pins,
    sortedPins,
    addPin,
    removePin,
    clearPins,
    toLineString,
    exportJSON,
    importJSON
  };
}

8) Map component with pins + path replay

We’ll build a client-only component that:

  • Renders a Mapbox map (Standard style, globe projection)
  • Lets you click to stage a new pin, fill a short form, and save it
  • Draws markers for saved pins
  • Builds a LineString and animates it using line-trim-offset
<!-- components/TravelMap.client.vue -->
<template>
  <div class="map-wrap">
    <div ref="mapEl" class="map"></div>

    <div class="controls">
      <button @click="fitToPins" :disabled="sortedPins.length === 0">Fit to pins</button>
      <button @click="replay" :disabled="!canReplay || isReplaying">
        {{ isReplaying ? 'Replaying...' : 'Replay path' }}
      </button>
      <button @click="stopReplay" :disabled="!isReplaying">Stop replay</button>
    </div>

    <div v-if="pendingLngLat" class="add-pin">
      <h3>Add Pin</h3>
      <form @submit.prevent="savePendingPin">
        <label>
          Title
          <input v-model="form.title" required placeholder="e.g. Kyoto" />
        </label>
        <label>
          Notes
          <textarea v-model="form.notes" placeholder="What did you do?"></textarea>
        </label>
        <label>
          Date
          <input v-model="form.date" type="date" required />
        </label>
        <div class="row">
          <span>Lng: {{ pendingLngLat.lng.toFixed(5) }}</span>
          <span>Lat: {{ pendingLngLat.lat.toFixed(5) }}</span>
        </div>
        <div class="row">
          <button type="submit">Save pin</button>
          <button type="button" @click="cancelPending">Cancel</button>
        </div>
      </form>
    </div>
  </div>
</template>

<script setup lang="ts">
import mapboxgl from 'mapbox-gl';
import { onMounted, onBeforeUnmount, ref, watch, computed, nextTick } from 'vue';
import { useTravelJournal } from '~/composables/useTravelJournal';

const routeSourceId = 'travel-route-source';
const routeLayerId = 'travel-route';
const markers: mapboxgl.Marker[] = [];

const mapEl = ref<HTMLDivElement | null>(null);
const map = ref<mapboxgl.Map | null>(null);

const { sortedPins, toLineString, addPin } = useTravelJournal();

const pendingLngLat = ref<{ lng: number; lat: number } | null>(null);
const form = ref({
  title: '',
  notes: '',
  date: new Date().toISOString().slice(0, 10)
});

const isReplaying = ref(false);
let rafId: number | null = null;

const canReplay = computed(() => {
  const ls = toLineString();
  return !!ls && ls.geometry.coordinates.length >= 2;
});

function ensureMap(): asserts map is { value: mapboxgl.Map } {
  if (!map.value) throw new Error('Map not ready');
}

function initMap() {
  if (!mapEl.value) return;

  map.value = new mapboxgl.Map({
    container: mapEl.value,
    style: 'mapbox://styles/mapbox/standard', // Mapbox Standard
    center: [0, 20],
    zoom: 1.8,
    pitch: 0,
    bearing: 0,
    antialias: true
  });

  map.value.addControl(new mapboxgl.NavigationControl(), 'top-right');

  map.value.on('load', () => {
    // Globe projection looks fantastic in Standard
    map.value?.setProjection?.('globe');
    syncMarkers();
    buildOrUpdateRoute();
  });

  map.value.on('click', (e) => {
    pendingLngLat.value = { lng: e.lngLat.lng, lat: e.lngLat.lat };
    // Prefill date to today
    form.value.date = new Date().toISOString().slice(0, 10);
  });
}

function cancelPending() {
  pendingLngLat.value = null;
  form.value = {
    title: '',
    notes: '',
    date: new Date().toISOString().slice(0, 10)
  };
}

function savePendingPin() {
  if (!pendingLngLat.value) return;
  addPin({
    title: form.value.title.trim(),
    notes: form.value.notes.trim() || undefined,
    date: form.value.date,
    coords: { ...pendingLngLat.value }
  });
  cancelPending();
  // Sync visuals after next tick
  nextTick(() => {
    syncMarkers();
    buildOrUpdateRoute();
  });
}

function clearMarkers() {
  while (markers.length) {
    const m = markers.pop();
    m?.remove();
  }
}

function syncMarkers() {
  ensureMap();
  clearMarkers();

  for (const p of sortedPins.value) {
    const el = document.createElement('div');
    el.className = 'marker';
    const marker = new mapboxgl.Marker({ element: el })
      .setLngLat([p.coords.lng, p.coords.lat])
      .setPopup(
        new mapboxgl.Popup({ offset: 12 }).setHTML(`
          <div style="min-width:180px">
            <strong>${escapeHtml(p.title)}</strong><br/>
            <small>${p.date}</small>
            ${p.notes ? `<p style="margin-top:6px">${escapeHtml(p.notes)}</p>` : ''}
          </div>
        `)
      )
      .addTo(map.value!);

    markers.push(marker);
  }
}

function buildOrUpdateRoute() {
  ensureMap();
  if (!map.value?.isStyleLoaded()) {
    // If style not loaded yet, wait for it
    map.value?.once('load', buildOrUpdateRoute);
    return;
  }

  const feature = toLineString();
  const src = map.value.getSource(routeSourceId) as mapboxgl.GeoJSONSource | undefined;

  if (!feature) {
    // Remove if exists
    if (map.value.getLayer(routeLayerId)) map.value.removeLayer(routeLayerId);
    if (src) map.value.removeSource(routeSourceId);
    return;
  }

  if (!src) {
    map.value.addSource(routeSourceId, {
      type: 'geojson',
      data: feature
    });

    map.value.addLayer({
      id: routeLayerId,
      type: 'line',
      source: routeSourceId,
      layout: {
        'line-cap': 'round',
        'line-join': 'round'
      },
      paint: {
        'line-color': '#1e90ff',
        'line-width': 4,
        'line-opacity': 0.9,
        // We'll animate this from [0, 0] to [0, 1]
        'line-trim-offset': [0, 0]
      }
    });
  } else {
    src.setData(feature as unknown as GeoJSON.Feature);
  }
}

function fitToPins() {
  ensureMap();
  if (sortedPins.value.length === 0) return;
  if (sortedPins.value.length === 1) {
    const only = sortedPins.value[0].coords;
    map.value!.easeTo({ center: [only.lng, only.lat], zoom: 10, duration: 800 });
    return;
  }
  const bounds = new mapboxgl.LngLatBounds();
  sortedPins.value.forEach(p => bounds.extend([p.coords.lng, p.coords.lat]));
  map.value!.fitBounds(bounds, { padding: 60, duration: 900 });
}

// Route replay using line-trim-offset
function replay() {
  ensureMap();
  if (!canReplay.value) return;
  isReplaying.value = true;

  const feature = toLineString()!;
  const totalPoints = feature.geometry.coordinates.length;
  // Duration scales with route length (rough but effective)
  const duration = Math.max(1600, (totalPoints - 1) * 1000);
  const start = performance.now();

  // Reset the line first
  map.value!.setPaintProperty(routeLayerId, 'line-trim-offset', [0, 0]);

  const step = (now: number) => {
    if (!isReplaying.value) return;
    const t = Math.min(1, (now - start) / duration);
    const eased = easeInOutCubic(t);

    // Animate the end trim from 0 -> 1
    map.value!.setPaintProperty(routeLayerId, 'line-trim-offset', [0, eased]);

    // Optional: keep camera gently following along the route
    const idx = Math.min(
      totalPoints - 1,
      Math.floor(eased * (totalPoints - 1))
    );
    const coord = feature.geometry.coordinates[idx];
    map.value!.easeTo({
      center: coord as [number, number],
      duration: 200,
      zoom: Math.max(3, Math.min(7, map.value!.getZoom())),
      pitch: 30
    });

    if (t < 1) {
      rafId = requestAnimationFrame(step);
    } else {
      isReplaying.value = false;
    }
  };

  rafId = requestAnimationFrame(step);
}

function stopReplay() {
  if (rafId != null) cancelAnimationFrame(rafId);
  rafId = null;
  isReplaying.value = false;
}

function escapeHtml(s: string) {
  return s.replace(/[&<>"']/g, (c) => ({
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  }[c] as string));
}

function easeInOutCubic(x: number) {
  return x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2;
}

onMounted(() => {
  initMap();
});

// Keep map in sync when pins change
watch(
  () => sortedPins.value.map(p => p.id + p.date).join('|'),
  () => {
    if (!map.value) return;
    syncMarkers();
    buildOrUpdateRoute();
  }
);

onBeforeUnmount(() => {
  stopReplay();
  clearMarkers();
  map.value?.remove();
});
</script>

<style scoped>
.map-wrap {
  position: relative;
}
.map {
  width: 100%;
  height: 70vh;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 8px 20px rgba(0,0,0,0.1);
}
.controls {
  position: absolute;
  left: 12px;
  top: 12px;
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
  background: rgba(255,255,255,0.85);
  padding: 8px;
  border-radius: 8px;
  backdrop-filter: blur(6px);
}
.controls button {
  padding: 6px 10px;
  border: 1px solid #dcdcdc;
  border-radius: 6px;
  background: white;
  cursor: pointer;
}
.controls button:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}
.add-pin {
  position: absolute;
  right: 12px;
  top: 12px;
  width: 300px;
  background: white;
  border-radius: 10px;
  padding: 12px;
  box-shadow: 0 6px 18px rgba(0,0,0,0.12);
}
.add-pin form {
  display: grid;
  gap: 8px;
}
.add-pin input, .add-pin textarea {
  width: 100%;
  padding: 6px 8px;
  border: 1px solid #e5e5e5;
  border-radius: 6px;
}
.add-pin .row {
  display: flex;
  gap: 10px;
  align-items: center;
  justify-content: space-between;
}
.marker {
  width: 14px;
  height: 14px;
  border-radius: 50%;
  box-shadow: 0 0 0 2px #fff;
  background: #e53935;
}
</style>

Notes:

  • We attach Standard style and switch to globe projection on load
  • We animate the route with line-trim-offset from 0 to 1 for a smooth “drawing” effect

9) The page: list pins, delete, import/export

A simple page that renders the map and a side drawer with your saved pins.

<!-- pages/index.vue -->
<template>
  <section class="page">
    <header class="hero">
      <h1>Personal Travel Journal</h1>
      <p>Click the map to add pins, then replay your path with smooth animation.</p>
    </header>

    <div class="grid">
      <TravelMap />

      <aside class="side">
        <h3>Your Pins ({{ sortedPins.length }})</h3>
        <ul v-if="sortedPins.length > 0" class="pins">
          <li v-for="p in sortedPins" :key="p.id">
            <div class="pin-main">
              <strong>{{ p.title }}</strong>
              <small>{{ p.date }}</small>
            </div>
            <div class="pin-sub" v-if="p.notes">{{ p.notes }}</div>
            <div class="pin-geo">
              <code>{{ p.coords.lng.toFixed(4) }}, {{ p.coords.lat.toFixed(4) }}</code>
              <button class="danger" @click="removePin(p.id)">Delete</button>
            </div>
          </li>
        </ul>
        <p v-else>No pins yet. Click the map to add your first stop.</p>

        <div class="io">
          <button @click="copyExport">Export JSON</button>
          <button class="danger" @click="clearPins" :disabled="sortedPins.length === 0">Clear All</button>
        </div>

        <details>
          <summary>Import JSON</summary>
          <textarea v-model="importText" rows="6" placeholder='[ { "id": "...", "title": "...", "date": "YYYY-MM-DD", "coords": { "lng": 0, "lat": 0 } } ]'></textarea>
          <button @click="doImport">Import</button>
        </details>
      </aside>
    </div>
  </section>
</template>

<script setup lang="ts">
import TravelMap from '~/components/TravelMap.client.vue';
import { useTravelJournal } from '~/composables/useTravelJournal';

const { sortedPins, removePin, clearPins, exportJSON, importJSON } = useTravelJournal();
const importText = ref('');

async function copyExport() {
  const data = exportJSON();
  await navigator.clipboard.writeText(data);
  alert('Exported to clipboard!');
}

function doImport() {
  try {
    importJSON(importText.value);
    alert('Imported successfully!');
  } catch (e) {
    alert('Import failed. Check console for details.');
    console.error(e);
  }
}
</script>

<style scoped>
.page {
  padding: 24px;
}
.hero {
  margin-bottom: 16px;
}
.grid {
  display: grid;
  grid-template-columns: 1fr 340px;
  gap: 16px;
}
.side {
  background: #fff;
  border: 1px solid #eee;
  border-radius: 12px;
  padding: 12px;
  height: fit-content;
  position: sticky;
  top: 16px;
}
.pins {
  list-style: none;
  padding: 0;
  margin: 0 0 12px;
  display: grid;
  gap: 10px;
}
.pins li {
  border: 1px solid #eee;
  border-radius: 10px;
  padding: 10px;
}
.pin-main {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
}
.pin-sub {
  margin-top: 6px;
  color: #333;
}
.pin-geo {
  margin-top: 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.io {
  display: flex;
  gap: 8px;
  margin: 8px 0 12px;
}
button {
  padding: 6px 10px;
  border: 1px solid #dcdcdc;
  border-radius: 6px;
  background: white;
  cursor: pointer;
}
button.danger {
  border-color: #f1c4c4;
  color: #a12828;
}
textarea {
  width: 100%;
  margin-top: 6px;
  border-radius: 8px;
  border: 1px solid #eee;
  padding: 8px;
}
@media (max-width: 980px) {
  .grid {
    grid-template-columns: 1fr;
  }
}
</style>

10) Try it out

  • npm run dev
  • Click the map to add pins (title, notes, date)
  • Click “Replay path” to see your route animate with line-trim-offset
  • “Fit to pins” adjusts the camera to your trip
  • Export/Import to move data between devices

Optional enhancements

  • Add offline installability with @vite-pwa/nuxt
  • Persist to Firebase Firestore for multi-device sync
  • Attach photos in pins and show them in popups
  • Add geocoding to search places (Mapbox Geocoding API)
  • 3D terrain: add a raster-dem source and setTerrain for mountainous routes

Troubleshooting

  • Blank map: ensure NUXT_PUBLIC_MAPBOX_TOKEN is set and your token has Styles: Read scope
  • Route not animating: ensure you installed mapbox-gl v3.5.0 or newer and that there are at least 2 pins
  • Typescript errors for GeoJSON: we cast the GeoJSON feature to keep the setup lightweight, but you can install @types/geojson for stricter typing

Conclusion

You’ve built a modern, local-first travel journal with Nuxt 4 + TypeScript and Mapbox GL JS v3. Using the new Standard style, globe projection, and line-trim-offset, you get a polished map and a smooth path replay with minimal code.

Happy travels and happy hacking!