Codementor Events

How I Built an Adventure Dashboard with Next.js, Notion and Google Maps

Published Feb 05, 2025
How I Built an Adventure Dashboard with Next.js, Notion and Google Maps

(Originally published here)

My wife and I are avid adventurers, regularly engaging in activities like hiking, biking, snowshoeing, and traveling. Over the past two years, we've been documenting these experiences across various platforms (Google Photos, Instagram, and AllTrails) and collating them on Notion. Although Notion has been an excellent organizational tool, it lacks a crucial element: a visually engaging way to appreciate how far and wide we have explored.

Initially, I experimented with creating custom Google Maps for each trip using MyMaps. While this solved the issue of visual engagement, it introduced another one: the time-consuming process of adding data to Google Maps manually. What we needed was a platform that consolidated all our activities and memories, looked more appealing than a relational database, and automatically synced with our Notion data. Bonus points if it could be shared with friends and family. Hence, the idea of a web-based adventure dashboard was born.


The Planning

Agile Development at Home: Making My Wife the Product Owner

Although I’ve done a fair amount of front-end development, there were two major challenges while planning this dashboard:

  1. Lack of UX Design Expertise
    My experience in UX design was limited to admiring hundreds of Dribbble templates and reading Refactoring UI by Adam Wathan and Steve Schoger.
  2. Functional Fixedness
    Having two years’ worth of data already in Notion meant I risked shaping the dashboard around the existing database format instead of reimagining the data to suit the dashboard’s needs.

The Solution: Run the project as an Agile development exercise, with my wife taking on the roles of Client and Product Owner. I wore the hats of the Developer, Business Analyst, and QA.

  • We had an Inception phase, where we collaboratively determined the features for a Minimum Viable Product, and iterated through desktop and mobile mockups.
  • I broke the features into manageable tasks and created a Kanban board. It had the added benefit of helping me stay motivated and focused.
  • I checked-in with her on the progress regularly (Sprint Review), and adjusted subsequent tasks based on our discussions.

1_7vv_lbRlDPFS8cDC-c3onw.webpThe Kanban board

The end result was that my wife, as an objective and invested stakeholder, helped me prioritize user requirements and plan a sensible UX. It also helped me avoid the pitfalls of getting lost in implementation complexity, and adding flashy gimmicks.

The Domain Model

  • There are 3 types of activities that we wanted to showcase as ‘pins’ on the Map — travel (multiple day vacations), hikes and bike rides.
  • Each activity may or may not have links to other platforms — Google Photo album, Instagram, AllTrails and a Notion blogpost.
  • One travel activity can have multiple pins, as we often explore several places during a single trip.
  • Some of these activities are repeated every year (for e.g.: a bike ride around Hagg Lake every summer). So a single pin should be able to showcase multiple activities as well.
  • A travel activity pin should show the location name, the people we went with and the duration of the trip.
  • A hike or bike ride should show distance, elevation gained and steepness (grade).

User Features:

  1. Filters: The user should be able to filter by participant (me, my wife, or both of us), year of activity, activity type, distance and elevation. The latter two are applicable to hikes and bike rides only.
  2. Initial page load filter: Since new activities are being regularly created and viewed on the dashboard, the map should be pre-filtered on initial load to show only the latest year’s activities. It should then switch to the previous year if there is no activity yet in the latest year.
  3. Milestone mode: Out of all the hikes and bike rides, the user should be able to see milestones like ‘longest hike’, ‘most elevation gained’, etc.
  4. User roles and permissions: Guests and I should see distances in kilometers, while my wife sees them in miles. Also, guests should not be able to access Google Photos and Notion blogpost links.
    Have a look here to see how the TypeScript definitions turned out.

The Development

Travel DB Structure

1_H2mOd7vUsrmFNqpVRzEVHw.webpTravel DB sample data

Note:

  1. Count of Places and pairs of Coordinates must match.
  2. The Coordinate pairs are separated by new lines.
{
  Instagram: {
    id: 'HTPD',
    type: 'url',
    url: 'https://www.instagram.com/p/xxxx'
  },
  'Done by': { id: 'Omdr', type: 'people', people: [
    {
      object: 'user',
      id: '3212c793-ad17-436d-a85b-5a7d919a5431',
      name: 'Jason Statham',
      avatar_url: 'https://lh3.googleusercontent.com/a/xxxxx',
      type: 'person',
      person: [Object]
    }
  ] },
  Coordinates: { id: 'R%60Ew', type: 'rich_text', rich_text: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: '45.562175504305515, -123.00034463018213',
      href: null
    }
  ] },
  'Google Photos Album': {
    id: 'RaUw',
    type: 'url',
    url: 'https://photos.app.goo.gl/xxxx'
  },
  People: { id: 'VQc%5D', type: 'rich_text', rich_text: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: 'Jason, Jane, Joan',
      href: null
    }
  ] },
  'Travel Status': {
    id: 'YsHl',
    type: 'status',
    status: { id: 'T}S{', name: 'Visited', color: 'green' }
  },
  'Journal Status': {
    id: '%60pQP',
    type: 'status',
    status: { id: 'T}S{', name: 'Complete', color: 'green' }
  },
  Date: {
    id: 'qXcn',
    type: 'date',
    date: { start: '2023-05-05', end: '2023-05-14', time_zone: null }
  },
  Places: { id: 'tRwb', type: 'rich_text', rich_text: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: 'Batman Cave',
      href: null
    }
  ] },
  Name: { id: 'title', type: 'title', title: [ [Object] ] }
}

Outdoor DB Structure

1__PhBYBTS8otjyXjOg9LH_g.webpOutdoor DB sample data

{
  'Elevation (ft)': { id: 'JSF%3F', type: 'number', number: 350 },
  Date: {
    id: 'N%40%7Bz',
    type: 'date',
    date: { start: '2025-01-19', end: null, time_zone: null }
  },
  Coordinates: { id: 'OFY%3C', type: 'rich_text', rich_text: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: '45.562175504305515, -123.00034463018213',
      href: null
    }
  ] },
  'All Trails': {
    id: 'RPz%5D',
    type: 'url',
    url: 'https://www.alltrails.com/trail/canada/alberta/johnston-canyon-to-lower-falls'
  },
  Tags: {
    id: 'Ryjl',
    type: 'multi_select',
    multi_select: [
    {
      id: '8d504597-9996-4f57-8987-26613d00434d',
      name: 'Biking',
      color: 'pink'
    }
  ]
  },
  'Google Photos Album': { id: 'W%3FI%7D', type: 'url', url: null },
  Instagram: { id: 'Yh%3BT', type: 'url', url: null },
  'Done by': { id: 'hcaA', type: 'people', people: [
    {
      object: 'user',
      id: '3212c793-ad17-436d-a85b-5a7d919a5431',
      name: 'Jason Statham',
      avatar_url: 'https://lh3.googleusercontent.com/a/xxxxx',
      type: 'person',
      person: [Object]
    }
  ] },
  'Distance (miles)': { id: 'lwnd', type: 'number', number: 1.5 },
  Location: { id: 'thu%7D', type: 'rich_text', rich_text: [
    {
      type: 'text',
      text: [Object],
      annotations: [Object],
      plain_text: 'Jackson School-Glenco Rd',
      href: null
    }
  ] },
  Name: { id: 'title', type: 'title', title: [ [Object] ] }
}

Wrapper Endpoint

The API wrapper endpoint acts as an aggregation layer, normalizing data from two Notion API calls into a HashMap keyed by Coordinates. The HashMap helps create the many-to-many relationship between activities and Google Map pins — there is one array for every pair of Coordinates and all activities that have the same Coordinates are pushed into one array.
1_6skEkRza9XNymbCFY0JIWw.webp

View code here and here.

React Google Map

To create a dynamically updating Map, a project has to be first set up on Google Cloud. Next, an API key has to be created with Google Maps API enabled (steps here). As a general security practice, there should be different keys for local and production environments.

Once the user logs in and lands on the home page, the wrapper endpoint is invoked (view) to fetch all data for the map and filters. The pins are then rendered using vis.gl/react-google-maps. The library’s popups (InfoWindow) are used in mobile mode for on-click events.

Every filter execution refreshes the map’s data while keeping the original data untouched. This greatly reduces network usage, and also lays the groundwork for a Progressive Web App in a future release.

View code here.

Thumbnail

Using the Google Photo Album links, it is possible to scrape the album cover image links using open-graph-scraper and use them as thumbnails for activities.

How it works:

  1. On-Demand Scraping: The scraping is done asynchronously when a user clicks on a specific pin, via an API endpoint. Reason: This approach avoids pre-fetching thumbnails for all activities, saving resources, as a user is unlikely to browse all pins in a single session.
  2. Local Caching: The thumbnail link is stored in the browser’s local storage for an extended period of time (days to a week). Reason: An album cover image is unlikely to change after the first few days of its creation. This reduces redundant API calls.
  3. Guest Mode Restrictions: Thumbnails are not fetched for guests. Reason: Google Photos are accessible only to the primary users (me or my wife).
  4. Logout Cache Clearing: Local storage is cleared on logout. Reason: Prevents scenarios where a device shared between a primary user and a guest could expose private album links to unauthorized users.
    View thumbnail endpoint here and browser caching here.

SEO and image previews

To make the dashboard more discoverable and shareable, I focused on SEO optimization and creating engaging social media previews. For example,

  1. Open graph tags — to describe the website’s content for Facebook and many other sites.
  2. Twitter Card tags — similar to the above for Twitter.
  3. Robot tags — to instruct search engines on how to index pages.
  4. Verification tags — to prove website ownership to search engines and gain access to advanced analytics of traffic driven through them.
  5. GitHub repos also have their own social media preview setting (see steps).
  6. jsDelivr was used as a free CDN to host the social media preview images (see example).
  7. A duplicate of the logo image was created with the name apple-touch-icon.png, to make it compatible as an Apple Web App. This lets iOS users save the site as a home screen shortcut.
    View code here.

Optimizations

Caching

  1. E-tag: Since the Notion data does not change frequently (about a couple of times a month), the HTTP caching header E-tag (see library and protocol) is used with the wrapper endpoint. When the data does not change between two sessions, the backend sends 304 Not Modified. This reduces the network load by 97%! (26.5kB to 760 bytes)
  2. Cache-Control: To capitalize further on infrequent data changes Cache-Control header is also used, with an expiration of 6 hours. This prompts the browser to cache the endpoint response and not call the wrapper endpoint until the validity expires. View code.
  3. Reduce payload size: To reduce the size of the first ever endpoint call, all empty data (for example, empty Instagram links) are removed from the payload (view). This reduced the size by 10% (29.15kB to 26.5kB).

Image optimization

  1. Resized Assets: Once the design was built and stabilized, the app images were resized to their rendered dimensions. For example, if an image of size 40x40 pixels is rendered at a fixed size of 30x30 on the web app, it is replaced with a resized image of size 30x30.

Security

  1. Authjs.dev: The web app uses Authjs.dev for authentication and session validation. At the time of writing, the version is 5.0.0 beta. The documentation isn’t complete and some TypeScript definitions are missing. But it was a mandatory dependency of Next.js v15.1.
  2. Environment Variable Handling: Next.js has a provision to add the prefix NEXT_PUBLIC_ to an environment variable to make it available to the browser. Without this prefix, the variable is accessible only in the backend, ensuring that sensitive keys are not inadvertently exposed.
  3. Key access restriction: Since the Google Maps library is dynamic and needs the API key in the browser, there is a risk of a malicious actor accessing it and impersonating my Google Cloud account. To safeguard against this, the key is restricted to be used only for Google Maps API, and accessed only by my website domain (https://travel-history-viewer.vercel.app/).
  4. Credential encryption: Given the limited user base (my wife, me and ‘Guest’), a database is unnecessary, and the username and password are stored as encrypted environment variables using bcrypt.

Future

  1. “Stats” screen — Aggregate metrics like lifetime distance hiked, highest peak summited, and annual trends for bike rides.
  2. “Planned Trips” screen — The Travel DB already has the capability of storing trips with future dates.
  3. Fuzzy search input — Find an activity by name or year, instead of sifting through data with filters.

Discover and read more posts from Ray Mathew
get started