Skip to main content

🧳 Weekend Project: The DOM & Browser APIs

You've learned the pieces β€” selecting elements, building nodes, wiring events, reading the Geolocation and Storage APIs. This weekend you'll snap them together into one real app: a Travel Explorer that browses destinations, finds ones near you, and remembers your favorites. No framework, no build step β€” just the browser, and you.

🎯 Learning Objectives

By the end of this project, you will be able to:

  • Render a dynamic grid of cards from a data array using DOM-creation methods rather than fragile string HTML
  • Wire search, filter, and view-toggle interactions with delegated event listeners
  • Integrate the Geolocation API to show destinations near the user, with graceful fallbacks
  • Persist favorites across sessions with the Web Storage (localStorage) API
  • Ship a small app you can evaluate against a clear "what good looks like" rubric

Estimated Time: 4–8 hours (a weekend)  β€’  Difficulty: Intermediate

Hands-on: This entire lesson is the exercise β€” you build the app milestone by milestone.

In This Lesson

What You're Building

The Travel Explorer is a single-page app that starts from a small array of destinations and grows into something genuinely useful. A user can scroll a grid of place cards, type to search, filter by continent, tap "Near me" to sort by distance from their real location, and star favorites that survive a page reload. Every one of those features is a direct application of something from this module.

We'll build it the way real projects get built: in milestones. Each milestone is a small, testable slice that leaves you with a working app β€” never a half-wired mess. If your weekend gets short, you can stop after any milestone and still have something you're proud to show.

πŸ“– Ground rules

Vanilla only. No React, no bundler, no npm. Three files: index.html, styles.css, app.js. Open index.html in your browser and refresh to test.

Progressive enhancement. The app must render and be browsable even if Geolocation is denied or Storage is unavailable. Features layer on top of a working base β€” they never gate it.

πŸ’‘ Why a weekend project matters. Isolated exercises teach one idea at a time. A project forces the ideas to coexist β€” the event listener has to update the same DOM the search rebuilt, using data the storage layer just changed. That integration is exactly the skill employers pay for.

Plan & Architecture

Before typing code, get the shape of the app in your head. Data flows one way: a single state object holds the truth, a render() function draws the DOM from that state, and event handlers change the state and re-render. This "state β†’ render" loop is the same idea frameworks automate for you β€” doing it by hand once makes those frameworks click later.

flowchart LR S["state
(destinations, query,
continent, favorites)"] --> R["render()"] R --> DOM["DOM: card grid"] DOM -->|"user clicks / types"| H["event handler"] H -->|"mutate + re-render"| S

Keeping one source of truth means bugs have one place to live. If the wrong cards show, you inspect state β€” not five scattered DOM tweaks.

Travel Explorer component map A central app module coordinates four concerns: data and state, the rendered card grid, the Geolocation feature, and the localStorage favorites layer. app.js state + render loop Data destinations[] Grid + Search DOM rendering Geolocation nearby sort Storage favorites
Figure 1 β€” One coordinator, four concerns. Build them left to right; each new box works on top of the last.

The Milestone Checklist

Here is the whole weekend at a glance. Tackle them in order β€” each one leaves you with a running app. Check them off as you go.

#MilestoneYou're done when…APIs used
1Scaffold & dataThree files exist; the destinations array loads and logs to the consoleβ€”
2Render the gridEvery destination appears as a card, built with DOM methodsDOM
3Search & filterTyping or changing the continent narrows the grid liveDOM, Events
4Geolocation"Near me" sorts cards by real distance, with a fallback messageGeolocation
5Favorites & storageStarred cards persist across a full page reloadWeb Storage

⚠️ Resist the urge to build everything at once

The classic weekend-project failure is opening all five milestones in one giant edit, then spending Sunday night hunting a bug across features that were never individually tested. Finish milestone N, reload the page, confirm it works, then start N+1. A working app at every step is the whole point.

Milestone 1 β€” Scaffold & Data

Create the three files. The HTML is deliberately skeletal β€” the JavaScript will fill the grid at runtime.

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Travel Explorer</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <header>
    <h1>🧳 Travel Explorer</h1>
  </header>

  <div class="controls">
    <input type="search" id="search" placeholder="Search destinations…" aria-label="Search">
    <select id="continent" aria-label="Filter by continent">
      <option value="">All continents</option>
      <option value="europe">Europe</option>
      <option value="asia">Asia</option>
      <option value="north-america">North America</option>
      <option value="south-america">South America</option>
      <option value="africa">Africa</option>
      <option value="oceania">Oceania</option>
    </select>
    <button id="near-me">πŸ“ Near me</button>
  </div>

  <p id="status" role="status" aria-live="polite"></p>
  <main id="grid" class="grid"></main>

  <script src="app.js"></script>
</body>
</html>

app.js β€” the data and state

Use const for the fixed dataset and a single mutable state object for everything that changes. Keep the array small; five or six destinations is plenty to prove the mechanics.

// app.js
const destinations = [
  { id: 'paris',    name: 'Paris',        country: 'France',   continent: 'europe',
    lat: 48.8566, lng: 2.3522,   tags: ['romantic', 'food', 'art'],
    blurb: 'The City of Light β€” the Eiffel Tower, the Louvre, and a cafΓ© on every corner.' },
  { id: 'kyoto',    name: 'Kyoto',        country: 'Japan',    continent: 'asia',
    lat: 35.0116, lng: 135.7681, tags: ['temples', 'traditional'],
    blurb: 'A thousand years of temples, gardens, and quiet lantern-lit streets.' },
  { id: 'cusco',    name: 'Cusco',        country: 'Peru',     continent: 'south-america',
    lat: -13.5320, lng: -71.9675, tags: ['history', 'mountains'],
    blurb: 'The Inca heartland and the gateway to Machu Picchu.' },
  { id: 'cape-town',name: 'Cape Town',    country: 'South Africa', continent: 'africa',
    lat: -33.9249, lng: 18.4241, tags: ['coast', 'mountains'],
    blurb: 'Table Mountain above, two oceans meeting below.' },
  { id: 'reykjavik',name: 'ReykjavΓ­k',    country: 'Iceland',  continent: 'europe',
    lat: 64.1466, lng: -21.9426, tags: ['nature', 'northern-lights'],
    blurb: 'Volcanoes, hot springs, and the aurora on a clear winter night.' },
  { id: 'queenstown',name: 'Queenstown',  country: 'New Zealand', continent: 'oceania',
    lat: -45.0312, lng: 168.6626, tags: ['adventure', 'lakes'],
    blurb: 'The adventure capital of the Southern Alps.' }
];

// Single source of truth for everything that can change.
const state = {
  query: '',
  continent: '',
  sortByDistance: false,
  userCoords: null,      // filled in by Geolocation later
  favorites: new Set()   // ids of favorited destinations
};

console.log(`Loaded ${destinations.length} destinations`, destinations);

βœ… Milestone 1 check

Open index.html. You should see the header and empty controls, and the console should log your six destinations. No grid yet β€” that's next.

Milestone 2 β€” Render the Grid

Now draw a card per destination. A common beginner shortcut is stuffing a big HTML string into innerHTML. It works, but it's brittle and unsafe with user data. Build nodes with document.createElement and set text with textContent β€” that path can never accidentally execute injected markup.

const grid = document.getElementById('grid');
const statusEl = document.getElementById('status');

function createCard(dest) {
  const card = document.createElement('article');
  card.className = 'card';
  card.dataset.id = dest.id;

  const title = document.createElement('h2');
  title.textContent = `${dest.name}, ${dest.country}`;

  const blurb = document.createElement('p');
  blurb.textContent = dest.blurb;

  const tags = document.createElement('div');
  tags.className = 'tags';
  for (const tag of dest.tags) {
    const chip = document.createElement('span');
    chip.className = 'tag';
    chip.textContent = tag;
    tags.append(chip);
  }

  const star = document.createElement('button');
  star.className = 'star';
  star.type = 'button';
  star.setAttribute('aria-pressed', String(state.favorites.has(dest.id)));
  star.textContent = state.favorites.has(dest.id) ? 'β˜… Saved' : 'β˜† Save';

  card.append(title, blurb, tags, star);

  // Show distance once we know where the user is.
  if (state.userCoords && dest.distance != null) {
    const badge = document.createElement('span');
    badge.className = 'distance';
    badge.textContent = `${Math.round(dest.distance)} km away`;
    card.append(badge);
  }
  return card;
}

function render() {
  const list = visibleDestinations();          // defined in Milestone 3
  grid.replaceChildren();                        // clear without innerHTML = ''
  statusEl.textContent = `${list.length} destination${list.length === 1 ? '' : 's'}`;
  const frag = document.createDocumentFragment();
  for (const dest of list) frag.append(createCard(dest));
  grid.append(frag);
}

πŸ’‘ Why a DocumentFragment?

Appending each card to the live grid one at a time forces the browser to recalculate layout on every insert. Building them inside a DocumentFragment first and appending once means a single reflow β€” noticeably smoother as your list grows.

For this milestone, stub visibleDestinations to just return everything, call render(), and confirm all six cards appear:

function visibleDestinations() { return destinations; } // temporary
render();

βœ… Milestone 2 check

Six cards render, each with a title, blurb, tags, and a "β˜† Save" button. The status line reads "6 destinations".

Milestone 3 β€” Search & Filter

Replace the stub with real filtering that reads from state, then wire the inputs so any change updates state and re-renders. This is the heart of the state→render loop.

function visibleDestinations() {
  const q = state.query.trim().toLowerCase();

  let list = destinations.filter(d => {
    const matchesContinent = !state.continent || d.continent === state.continent;
    const matchesQuery = !q ||
      d.name.toLowerCase().includes(q) ||
      d.country.toLowerCase().includes(q) ||
      d.tags.some(t => t.toLowerCase().includes(q));
    return matchesContinent && matchesQuery;
  });

  if (state.sortByDistance && state.userCoords) {
    list = [...list].sort((a, b) => a.distance - b.distance);
  }
  return list;
}

// Wire the controls once, at startup.
document.getElementById('search').addEventListener('input', (e) => {
  state.query = e.target.value;
  render();
});

document.getElementById('continent').addEventListener('change', (e) => {
  state.continent = e.target.value;
  render();
});

Notice the card's Save button is created fresh on every render, so a single listener bound at creation time would work β€” but re-binding on every render is wasteful. A cleaner pattern is event delegation: one listener on the grid that figures out which card was clicked.

grid.addEventListener('click', (e) => {
  const star = e.target.closest('.star');
  if (!star) return;                       // clicked somewhere else on the card
  const id = star.closest('.card').dataset.id;
  toggleFavorite(id);                      // defined in Milestone 5
});

βœ… Milestone 3 check

Typing "japan" leaves only Kyoto. Choosing "Europe" leaves Paris and ReykjavΓ­k. Clearing both brings all six back. The status count updates each time.

Milestone 4 β€” Geolocation

Time to make the app feel personal. The Geolocation API is asynchronous and permission-gated, so wrap it in a promise and always plan for the user saying no. The app must stay fully usable whether they grant location or not.

function getPosition() {
  return new Promise((resolve, reject) => {
    if (!('geolocation' in navigator)) {
      reject(new Error('Geolocation is not supported by this browser.'));
      return;
    }
    navigator.geolocation.getCurrentPosition(
      (pos) => resolve({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
      (err) => reject(err),
      { enableHighAccuracy: true, timeout: 10_000, maximumAge: 300_000 }
    );
  });
}

// Haversine distance in kilometres between two lat/lng points.
function distanceKm(lat1, lng1, lat2, lng2) {
  const R = 6371;
  const toRad = (deg) => deg * Math.PI / 180;
  const dLat = toRad(lat2 - lat1);
  const dLng = toRad(lng2 - lng1);
  const a = Math.sin(dLat / 2) ** 2 +
            Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
  return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}

document.getElementById('near-me').addEventListener('click', async () => {
  statusEl.textContent = 'Finding your location…';
  try {
    const coords = await getPosition();
    state.userCoords = coords;
    state.sortByDistance = true;
    for (const d of destinations) {
      d.distance = distanceKm(coords.lat, coords.lng, d.lat, d.lng);
    }
    render();
  } catch (err) {
    // Graceful fallback β€” the app keeps working, unsorted.
    state.sortByDistance = false;
    statusEl.textContent = 'Location unavailable β€” showing all destinations.';
    console.warn('Geolocation failed:', err.message);
  }
});

⚠️ Two things that trip everyone up

Secure context required. Modern browsers only expose Geolocation over https:// or http://localhost. Opening the file directly as file:// may silently fail β€” run a local server (python -m http.server) and visit localhost.

Permission is not guaranteed. Test the denied path too: block location in your browser and confirm the fallback message shows and the grid still works.

βœ… Milestone 4 check

Clicking "Near me" and allowing location reorders the cards nearest-first, each showing a "… km away" badge. Denying location shows the fallback message and leaves the app fully browsable.

Milestone 5 β€” Favorites & Storage

The final milestone gives the app memory. localStorage stores strings only, so serialize with JSON.stringify on save and JSON.parse on load β€” and wrap both in try/catch, because storage can be full or disabled in private mode.

const STORAGE_KEY = 'travel-explorer:favorites';

function loadFavorites() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    state.favorites = new Set(raw ? JSON.parse(raw) : []);
  } catch (err) {
    console.warn('Could not read favorites:', err.message);
    state.favorites = new Set();
  }
}

function saveFavorites() {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify([...state.favorites]));
  } catch (err) {
    console.warn('Could not save favorites:', err.message);
  }
}

function toggleFavorite(id) {
  if (state.favorites.has(id)) state.favorites.delete(id);
  else state.favorites.add(id);
  saveFavorites();
  render();               // re-render so the star reflects the new state
}

// Load saved favorites, then draw for the first time.
loadFavorites();
render();

Because createCard already reads state.favorites to set the star's label and aria-pressed, favorited cards will correctly show "β˜… Saved" the moment the page loads. That's the payoff of a single source of truth: persistence needed no special rendering code.

What localStorage holds after saving two favorites

Key:   travel-explorer:favorites
Value: ["paris","kyoto"]

βœ… Milestone 5 check

Star two destinations, then reload the page (Ctrl/Cmd-R). Both come back showing "β˜… Saved". Un-starring one and reloading confirms removal persists too.

What Good Looks Like

A finished project isn't just "it runs." Grade yourself against this rubric β€” it's the same lens a reviewer or interviewer would use.

DimensionNeeds workβœ… Good
DOM building One giant innerHTML string with data interpolated in createElement + textContent; user text can't inject markup
State Truth scattered across DOM attributes and globals One state object; UI is a pure function of it
Events A listener re-bound to every card on every render Delegated listener on the grid; bound once
Resilience App breaks if location is denied or storage is off Every API call has a fallback; base app always works
Accessibility Clickable <div>s, no labels, no live region Real <button>s, aria-pressed, aria-live status

πŸ’‘ Self-review pass

Before you call it done: open DevTools, deny location, and confirm the app still browses and filters. Then set your browser to block storage and confirm favorites simply stop persisting rather than throwing. Resilience is what separates a demo from an app.

Stretch Goals

Finished early, or want to push further next weekend? Each of these introduces one new browser capability without rewriting what you have.

  • Details dialog. Use the native <dialog> element and its showModal() method to show a longer write-up when a card is clicked β€” no custom modal CSS needed.
  • Web Share API. Add a "Share" button that calls navigator.share({ title, text, url }) on supported devices, falling back to copy-to-clipboard.
  • IndexedDB. Graduate from localStorage to IndexedDB when your data outgrows simple string blobs.
  • Service Worker. Cache the shell so the app opens even fully offline β€” true progressive-web-app territory.
  • Real data. Swap the hardcoded array for a fetch() against a public destinations JSON, with a loading state and error handling.

πŸ“– A note on the process

You just followed a plan β†’ build in slices β†’ review loop. That rhythm β€” understand, plan, execute in testable increments, then look back and refine β€” is the backbone of every real engineering project, from a weekend app to a production system.

Summary & Quiz

πŸŽ‰ Key Takeaways

  • Build in milestones β€” each slice leaves you with a working, testable app.
  • Keep one source of truth (state) and derive the DOM from it with a render() function.
  • Prefer createElement + textContent over innerHTML strings for safety and clarity.
  • Every browser API β€” Geolocation, Storage β€” needs a graceful fallback; the base app must never depend on them.
  • Use event delegation so one listener handles a whole grid of dynamic cards.

🎯 Quick Quiz

Question 1: Why does this project build the app in ordered milestones instead of writing all features at once?

Question 2: The "Near me" feature must keep working even if the user denies location. What's the right way to handle that?

Question 3: Favorites are stored with localStorage.setItem(key, JSON.stringify([...favorites])). Why the JSON.stringify?

πŸ“š Further Reading

πŸš€ What's Next?

Your Travel Explorer leans on asynchronous browser APIs β€” Geolocation resolves a promise, and a future fetch() would too. Next module we slow down and study that machinery directly: how synchronous vs. asynchronous code actually runs, and why the event loop lets a single-threaded language stay responsive.

πŸŽ‰ You shipped an app!

DOM, events, Geolocation, and Storage β€” all working together in something real. That's a portfolio piece.