Skip to main content
All projects
Web Apps
/projects/mypostcards

My Postcards: Personal Photo Journal

project.mypostcards.local
My Postcards: Personal Photo Journal project screenshot
  1. Problem:

    Travel and café photos sit unsorted in a camera roll or get lost in a social feed, with no sense of where they were taken, which trip they belong to, or the story behind them.

  2. Solution:

    Built a photo-first journal where every postcard is a file in the repository: a build-time pipeline validates each entry, measures every photo, reads places from the photographs' own GPS, and generates a static site with trips, cafés, places, a WebGL globe, a live route map, search and a visitor guest book.

  3. Impact:

    282 postcards (28 of them silent video clips), 3 trips, 11 cafés and 18 places, prerendered as 665 static pages with per-page share images, an image and video sitemap, an RSS feed and 0 npm audit vulnerabilities.

Overview

My Postcards is my personal photo journal: travel, cafés, city streets, food and the odd random click, kept as digital postcards. Content is authored as JSON and Markdown in the repository, not in a CMS. A build script validates every entry, measures every photograph, derives each place from the photos' own GPS, and writes one journal file that the Next.js 16 App Router (Cache Components) slices into static pages. Trips get a day-by-day diary and a live route map, cafés get a guide card with a rating, places appear on a WebGL globe and a server-drawn map, and visitors can leave a card on a guest wall. Supabase holds only those visitor cards; everything else is static.

Results & metrics

  • 282 postcards, 3 trips, 11 cafés and 18 places, all authored as files in the repo
  • 665 static pages prerendered, with share images, an image and video sitemap and an RSS feed
  • Globe per-frame cost cut from 14.3 ms to 0.2 ms
  • 0 npm audit vulnerabilities after the Next.js 16.4.3 security update

Before / after

  • Adding a postcard

    Before: Rows typed by hand into a Supabase dashboard

    After: A JSON entry and a photo in the repo, validated at build

  • Places and pins

    Before: Listed and positioned by hand

    After: Derived from each photograph's own GPS and OpenStreetMap

  • Globe per-frame cost

    Before: 14.3 ms a frame re-styling the whole page

    After: 0.2 ms a frame

  • Search Console sitemap

    Before: Couldn't fetch: invalid XML

    After: Valid image and video sitemap

What I built

Content checked at build

Every postcard is a file; the build checks each entry and photo and stops with the fix.

Places from the photos

Places and map pins come from each photograph's own GPS, so a new postcard adds its place.

A lighter globe

A WebGL globe pins every place; one fix cut its frame cost from 14.3 ms to 0.2 ms.

Stack

  • Next.js 16 App Router, with Cache Components and Partial Prefetching
  • React 19 and strict TypeScript, CSS Modules with light and dark design tokens
  • Build-time content pipeline, (Node + sharp) that validates entries and measures every photo
  • cobe WebGL globe, d3-geo SVG map rendered on the server, MapLibre route map loaded on demand
  • Supabase, for visitor cards only, written through a security definer function behind RLS
  • Vercel hosting, with AVIF/WebP images, a 31-day optimizer cache and Vercel Analytics

Decisions

Key trade-offs and design calls that shaped the final delivery.

  • Content as files in the repo instead of a Supabase CMS

    Why
    Context
    The first version kept postcards in Supabase, which meant typing rows into a dashboard, no review of changes, and a database round trip for a site whose content changes a few times a week
    Decision
    Moved all content into JSON and Markdown in the repository with a strict build-time validator; Supabase now holds only visitor cards, and a content change is a commit that Vercel redeploys
  • Places from photo GPS, not a hand-kept list

    Why
    Context
    Hand-placed map pins drift from where the photos were actually taken, and every new trip meant updating a list
    Decision
    Places and pins are derived from the postcards themselves, using the photographs' own GPS and OpenStreetMap, so adding a postcard adds or extends its place automatically
  • CSS and WAAPI over an animation library

    Why
    Context
    A photo site lives or dies on load time, and most of the motion is reveals, hovers and a few transitions
    Decision
    No framer-motion or Lenis: CSS, the Web Animations API and scroll-linked CSS animations, all respecting prefers-reduced-motion, with no scroll hijacking
  • Visitor cards public at once, not an approval queue

    Why
    Context
    An approval queue needs an admin UI and leaves visitors waiting to see their card
    Decision
    Cards appear on the wall straight away and the owner hides one by ticking a flag; links are rejected and drawings are re-validated stroke data, which keeps the abuse surface small

Architecture

The primary system boundaries, runtime pieces, and how the project was structured in production.

Diagram, 6 connections:
  1. Content JSON + Markdown → Validate + measure
  2. Photos + video clips → Validate + measure
  3. Validate + measure → Generated journal
  4. Generated journal → Next.js static pages
  5. Next.js static pages → Globe + maps
  6. Next.js static pages ↔ Supabase visitor cards
Groups: Authoring: Content JSON + Markdown, Photos + video clips; Build: Validate + measure, Generated journal; Site: Next.js static pages, Globe + maps; Runtime: Supabase visitor cards.
  • content/*.json + scripts/build-content.mjs

    Content Pipeline

    How

    Validates slugs, references, alt text and unknown field names (with suggestions), checks every local photo exists, measures dimensions and average colour, and derives places from postcard cities. Any mistake stops the build with the file name and the fix.

  • Next.js 16 App Router + Cache Components

    Static Site

    How

    One generated journal file sliced by server components; every postcard, trip, place, café and story page is prerendered with generateStaticParams, and client JavaScript is kept to small islands (lightbox, search, globe, visitor composer).

  • cobe + d3-geo + MapLibre

    Maps and Globe

    How

    The flat map is an SVG drawn on the server that ships no JavaScript; the trip route map loads MapLibre only when it scrolls near the screen; the globe pins every place from the photographs' own GPS.

  • Supabase (Postgres + RLS)

    Visitor Guest Book

    How

    The site holds only the anon key; cards are written through a security definer function, drawings are stored as stroke data and rendered as SVG paths, never HTML, and the owner hides cards instead of approving them.

Mermaid source

Paste into mermaid.live to edit the diagram.

flowchart LR
  subgraph Authoring
    C[Content JSON + Markdown]
    P[Photos + video clips]
  end
  subgraph Build
    V[Validate + measure]
    J[(Generated journal)]
  end
  subgraph Site
    N[Next.js static pages]
    M[Globe + maps]
  end
  subgraph Runtime
    S[(Supabase visitor cards)]
  end
  C --> V
  P --> V
  V --> J --> N
  N --> M
  N <--> S

Pipeline

How changes moved from development through validation and deployment.

Select a step for its detail

Incidents

Operational failures, rehearsals, or recovery moments that changed how the system was run.

  • P2

    Search Console couldn't fetch the sitemap

    Resolution

    A bare "&" in a video description ("Skyhouse Bar & Kitchen") made sitemap.xml invalid XML; video titles and descriptions are now escaped

    Lesson: Any free text that reaches XML must be escaped, and a sitemap needs validating as XML, not just eyeballing in a browser

  • P3

    A 5-second clip showed a 1:41 duration

    Resolution

    The first re-encode was recorded while the browser wasn't painting, so the file held a few frames padded to a long duration; both affected clips were re-encoded from the originals with ffmpeg and every clip's length was checked against its source

    Lesson: Verify media output against the source (duration, frames, audio track), not just that the file plays

  • P2

    The globe re-styled the whole page every frame

    Resolution

    The globe library attached a style element it read back on every frame; removing it cut the per-frame cost on the places page from 14.3 ms to 0.2 ms

    Lesson: Measure the per-frame cost of a third-party animation in the page, not in isolation