My Postcards: Personal Photo Journal

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.
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.
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
Places and pins
Before: Listed and positioned by hand
Globe per-frame cost
Before: 14.3 ms a frame re-styling the whole page
Search Console sitemap
Before: Couldn't fetch: invalid XML
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.
- Content JSON + Markdown → Validate + measure
- Photos + video clips → Validate + measure
- Validate + measure → Generated journal
- Generated journal → Next.js static pages
- Next.js static pages → Globe + maps
- Next.js static pages ↔ 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 <--> SPipeline
How changes moved from development through validation and deployment.
Runs before dev, typecheck and build; checks every entry and photo and stops with the file and the fix
Strict TypeScript and lint must pass before any change is called done
Prerenders 665 pages, share images, sitemap and RSS feed
Auto-deploy from GitHub with AVIF/WebP images and a 31-day image cache
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