There's something deeply satisfying about seeing your travels visualised on a map. Not just the places you've been, but the stories behind them—the photos, the dates, the memories. When I decided to add a travel map to my personal site, I knew it needed to be more than just pins on a map. It needed to be a living record of experiences, with the ability to easily add new destinations as life unfolds.
The result? An interactive travel map powered by Leaflet, with image storage via Vercel Blob, and a custom admin interface for managing travel data. But getting there involved solving some fascinating technical challenges, particularly around production deployment issues that only surface when you move from localhost to the real world.
The Vision: More Than Just Pins
The concept was straightforward: an interactive world map showing places I've travelled, with different coloured pins for solo trips, family adventures, and places we've all been together. Each pin would contain photos, dates, and descriptions. But the real magic would be in the data management—I wanted to extract GPS coordinates directly from photo metadata, making it effortless to add new destinations.
The technical requirements were clear:
- Interactive world map with custom markers
- Photo integration with Next.js image optimisation
- Automatic GPS data extraction from photo EXIF data
- Admin interface for data management
- Responsive design that works across all devices
- Integration with existing site architecture
Technology Choices: The Right Foundation
Next.js App Router
The choice of Next.js was obvious—it's what powers the rest of my site, and the App Router provides excellent support for both server and client components. For a feature like this, where you need server-side data processing (EXIF extraction) and client-side interactivity (the map), Next.js provides the perfect balance.
Leaflet for Mapping
While Google Maps might seem like the obvious choice, Leaflet offered several advantages:
- Open source with no API costs
- Excellent React integration via react-leaflet
- Highly customisable with full control over styling
- Uses OpenStreetMap tiles, which align with my preference for open standards
- Lightweight and performant
Vercel Blob for Image Storage
Vercel Blob proved ideal for this use case:
- Seamless integration with Next.js and Vercel deployment
- Built-in CDN for fast global image delivery
- Simple API for listing and accessing files
- Cost-effective for personal projects
- Automatic optimisation when combined with Next.js Image component
Phase 1: Building the Interactive Map
The first phase focused on creating the core mapping functionality. Starting with the data structure was crucial—it needed to match what we'd eventually extract from photo metadata:
export interface TravelPin {
id: number;
lat: number; // GPS latitude (decimal degrees)
lng: number; // GPS longitude (decimal degrees)
name: string; // Location name
date: string; // Visit date (YYYY-MM-DD)
description: string; // Description of the visit
photos: string[]; // Array of photo URLs
person: 'Ben' | 'Family' | 'All'; // Who travelled there
}
This structure directly mirrors EXIF GPS data format, making the eventual admin interface seamless. The lat
and lng
fields use decimal degrees—exactly what comes out of photo metadata.
The map component itself uses react-leaflet with custom icons for different travel categories:
const TravelMap = () => {
return (
<MapContainer
center={[0, 0]}
zoom={1}
zoomControl={false}
style={{ height: '100%', width: '100%' }}
>
<ZoomControl position="topright" />
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='© OpenStreetMap contributors'
/>
{travelData.map((pin: TravelPin) => (
<Marker
key={pin.id}
position={[pin.lat, pin.lng]}
icon={icons[pin.person]}
>
<Popup>
<div className={styles['popup-content']}>
<h3>{pin.name}</h3>
<p><strong>Date:</strong> {pin.date}</p>
<p>{pin.description}</p>
<div className={styles['popup-photos']}>
{pin.photos.map((photo, index) => (
<img
key={index}
src={photo}
alt={`${pin.name} photo ${index + 1}`}
className={styles['popup-photo']}
/>
))}
</div>
</div>
</Popup>
</Marker>
))}
</MapContainer>
);
};
The custom icons use different colours to distinguish between travel types—blue for solo trips, orange for family adventures, and green for places we've all been together. This visual distinction makes the map immediately readable and tells the story of different types of travel experiences.
Phase 2: The Admin Interface
The real innovation came in Phase 2: building an admin interface that could extract GPS data from photos and generate the JSON data structure automatically. This interface lives behind the existing /admin
authentication, ensuring it's secure but accessible when needed.
EXIF Data Extraction
The core of the admin interface is automatic GPS extraction from photo metadata:
import exifParser from 'exif-parser';
export async function extractExifData(imageUrl: string) {
try {
const response = await fetch(imageUrl);
const arrayBuffer = await response.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const parser = exifParser.create(buffer);
const result = parser.parse();
const { GPSLatitude, GPSLongitude } = result.tags;
const dateTaken = result.tags.DateTimeOriginal
? new Date(result.tags.DateTimeOriginal * 1000).toISOString().split('T')[0]
: null;
if (GPSLatitude && GPSLongitude) {
return {
lat: GPSLatitude,
lng: GPSLongitude,
date: dateTaken,
};
}
return { error: 'No GPS data found in image.' };
} catch (error) {
return { error: 'Failed to extract EXIF data.' };
}
}
This server action fetches images from Vercel Blob, extracts the GPS coordinates and date taken, and returns them in the exact format needed for the travel map. The beauty is that it works with any photo that has GPS metadata—whether from a phone, camera, or any other device.
The Admin UI
The admin interface provides a visual way to manage travel data:
const TravelMapManagerPage = () => {
const [images, setImages] = useState<ListBlobResultBlob[]>([]);
const [selectedImage, setSelectedImage] = useState<ListBlobResultBlob | null>(null);
const [pinData, setPinData] = useState<PinData | null>(null);
const handleImageSelect = async (image: ListBlobResultBlob) => {
setSelectedImage(image);
const data = await extractExifData(image.url);
if (!data.error) {
setPinData({
lat: data.lat,
lng: data.lng,
date: data.date,
name: '',
description: '',
person: 'Ben',
});
}
};
return (
<div className={styles.contentWrapper}>
<div className={styles.imageList}>
<h2>Available Images</h2>
{images.map((image) => (
<li key={image.pathname}>
<button onClick={()=> handleImageSelect(image)}>
<img src={image.url} alt={image.pathname} />
{image.pathname}
</button>
</li>
))}
</div>
<div className={styles.formContainer}>
{/* Form for manual data entry */}
<pre><code>{generatedJson}</code></pre>
</div>
</div>
);
};
The interface shows thumbnails of all images in Vercel Blob, automatically extracts GPS and date data when you select an image, provides form fields for manual entry of name, description, and person, and generates the final JSON object ready to copy into the travel data file.
This workflow makes adding new travel destinations effortless—upload photos to Vercel Blob, select them in the admin interface, fill in the details, and copy the generated JSON.
The Production Challenge: When Localhost Lies
Everything worked perfectly in development. The map rendered beautifully, pins appeared in the right locations, and the responsive design adapted flawlessly to different screen sizes. Then I deployed to production, and... nothing. Just a thin vertical red line where the map should be.
This is where the real learning happened. The issue wasn't with the code logic—it was with CSS layout compatibility between Leaflet and production build optimisations.
The Root Problem
Leaflet maps require explicit, calculable dimensions to render properly. In development, our flexbox layout worked fine:
.container {
display: flex;
flex-direction: column;
height: 100vh;
}
.map-area {
flex: 1; /* This worked locally but failed in production */
}
But in production, Vercel's build process optimises and minifies CSS differently. The flexbox flex: 1
wasn't being resolved to actual pixel dimensions, leaving Leaflet with a container that had no calculable height.
The Solution: Explicit Positioning
The fix was to abandon flexbox for absolute positioning:
.container {
position: relative;
min-height: 100vh;
}
.map-area {
position: absolute;
top: 120px;
left: 0;
right: 0;
bottom: 0;
}
This approach gives Leaflet explicit, calculable dimensions that work consistently across all environments. The map container knows exactly how much space it has, and Leaflet can render accordingly.
Additional Production Issues
There was also a Content Security Policy (CSP) issue blocking OpenStreetMap tiles:
// Fixed by explicitly allowing OpenStreetMap subdomains
const ContentSecurityPolicy = `
img-src * blob: data: a.tile.openstreetmap.org b.tile.openstreetmap.org c.tile.openstreetmap.org;
connect-src * a.tile.openstreetmap.org b.tile.openstreetmap.org c.tile.openstreetmap.org;
`;
OpenStreetMap uses multiple subdomains (a, b, c) for load balancing, and each needed to be explicitly allowed in the CSP.
Key Technical Insights
1. Layout Compatibility Matters
The choice between flexbox and absolute positioning isn't just about preference—it's about compatibility with third-party libraries that have specific requirements. Leaflet needs explicit dimensions, and absolute positioning provides that reliability.
2. Production Environment Differences
What works in development doesn't always work in production. Build optimisations, CSS processing, and server-side rendering can all introduce subtle differences that only surface when deployed.
3. EXIF Data is a Goldmine
Modern photos contain rich metadata that can automate much of the data entry process. GPS coordinates, timestamps, and even camera settings are all available if you know how to extract them.
4. Admin Interfaces Enable Scalability
Building a proper admin interface takes extra time upfront but pays dividends in long-term maintainability. Being able to easily add new travel destinations means the map will actually get updated regularly.
The Results: A Living Travel Record
The final implementation provides exactly what I envisioned:
- Interactive world map with custom markers for different travel types
- Rich popups with photos, dates, and descriptions
- Responsive design that works on all devices
- Easy data management through the admin interface
- Automatic GPS extraction from photo metadata
- Integration with Vercel Blob for efficient image storage
But more than the technical features, it's become a living record of experiences. Each pin tells a story, and the admin interface makes it effortless to add new chapters as they unfold.
Future Enhancements
The current implementation is just the beginning. Future enhancements could include:
- Route visualisation showing travel paths between destinations
- Timeline filtering to see travels by year or date range
- Photo galleries with full-screen viewing
- Travel statistics showing countries visited, distances travelled, etc.
- Integration with other services like Google Photos or Apple Photos
Lessons for Technical Leaders
This project reinforced several important principles:
Choose the Right Tools
Next.js, Leaflet, and Vercel Blob proved to be an excellent combination. Each tool excelled in its domain while integrating seamlessly with the others.
Plan for Production Early
The layout issues we encountered could have been avoided by testing production builds earlier in the development process. Always deploy early and often.
Automate What You Can
The EXIF extraction feature transforms what could be a tedious manual process into something effortless. Look for opportunities to automate repetitive tasks.
Build for the Long Term
The admin interface represents extra upfront work, but it ensures the travel map will actually be maintained and updated over time.
Conclusion
Building the travel map was more than just a technical exercise—it was about creating a meaningful way to visualise and share life experiences. The combination of Next.js, Leaflet, and Vercel Blob provided the perfect foundation for this vision.
The production deployment challenges were frustrating in the moment but ultimately led to a deeper understanding of how different environments can affect seemingly simple features. The lesson? Always test in production-like environments, and be prepared for layout and compatibility issues that don't surface in development.
Most importantly, the travel map has become exactly what I hoped—a living record of adventures that's easy to maintain and delightful to explore. Each pin represents not just a location, but a collection of memories, photos, and experiences that tell the story of a life well-travelled.
The future of personal websites is about more than just displaying information—it's about creating meaningful, interactive experiences that reflect who we are and what we've done. This travel map is a step in that direction, and the technical foundation ensures it can grow and evolve as new adventures unfold.
This implementation demonstrates how modern web technologies can be combined to create personal, meaningful features that go beyond traditional website functionality. The combination of Next.js, Leaflet, and Vercel Blob provides a powerful foundation for building interactive, data-rich experiences that enhance rather than complicate the user experience.