At a glance
- What it is: Cusp Africa puts African tourism and African open data in one place. The Tourism Hub covers destinations in all 54 countries, the Data Hub covers health, population, economy, and environment indicators, and a community blog lets people publish their own writing.
- My role: client work. The client brought an idea and a product requirements document. I did everything else: product management, UI/UX design, architecture, engineering, and launch.
- Where it is now: live at cuspafrica.com, with 27 indicators and around 40,000 data points, destinations for all 54 countries, and a sitemap of about 10,000 pages.
The problem
Good information about Africa is scattered. Development data sits in international portals built for specialists: the World Bank, WHO, UNICEF. Travel content is often thin or stereotyped. There was no single, credible place where someone could explore a country's destinations and its data side by side, with proper sources.
Cusp Africa connects the two. A destination links to its country's data, and an indicator links back to the country it describes. The editorial rules were strict from the start: no hype words, facts checked against official sources, and "when data is hard, we don't soften it."
Owning the whole product
I turned the client's requirements into a plan, designed every page, and built the platform. That meant making the product decisions as well as the technical ones. A few that shaped everything:
- Pages never call external APIs. Data is fetched on a schedule into my own database, and pages read only from there. Pages stay fast and don't break when an upstream service is slow or down.
- Server-first. Pages render on the server and are cached with sensible refresh windows: a minute for the blog feed, a day for country and indicator pages. There's no global client-side state; the URL holds what matters.
- Supabase for the backend. Postgres, authentication, file storage, and real-time updates in one service, with security enforced inside the database itself.
- Free tiers wherever possible, so the design had to respect their limits.
The Data Hub
Scheduled jobs pull 27 indicators from the World Bank, WHO, and UNICEF into Postgres. Every run is recorded in a log table with its counts, duration, and errors. Each indicator gets pages with maps, rankings, time-series charts, downloads (CSV, JSON, and XLSX), and citations in four formats. The whole dataset can also be downloaded as one file, streamed in pages so the server never holds all 40,000 rows in memory.
Each indicator page also describes itself as a dataset to search engines, including the licence of its source, so it can appear in Google Dataset Search.
Bug: six indicators permanently empty
Six of the 27 indicators showed no data in production, and the scheduled job looked healthy. Four separate problems were stacked on top of each other:
- The UNICEF job was a placeholder. It had been stubbed out early, and it reported success without fetching anything, so everything looked fine.
- The UNICEF request format was wrong. Its API expects the indicator in a specific position in the request key, and the number of parts varies by dataset. Get either wrong and it silently returns nothing useful.
- Two UNICEF indicator codes had been retired and replaced upstream.
- The World Bank had archived its CO₂ indicator. Its API answered "archived", which the code correctly read as "no data", so the job carried on with zero rows.
I rewrote the UNICEF client around a map of each dataset's structure, updated the retired codes without changing any public URLs, and re-ran everything. All 27 indicators filled up, for example one child-mortality indicator went from 0 to 1,890 rows, and I re-checked that the 21 working indicators were unchanged.
Fetching faster
The first World Bank import took over five minutes for just 15 countries, because it made one request per country per indicator. Switching to the endpoint that returns every country in one request brought the full import, all 54 countries, down to about 100 seconds.
The Tourism Hub
Destinations come from OpenStreetMap: about 13,000 across all 54 countries. The import couldn't run from my home connection because the OpenStreetMap API was unreachable from my network, so I ran it from GitHub Actions instead, as a workflow that retries failed countries safely.
Every country then got a hand-written profile and five featured destinations, through curation as code: one script per country that anyone can review, re-run, and diff. The rules were strict: every fact checked against official sources, every image licensed and credited, no AI-written copy and no AI imagery. Along the way that meant correcting a travel-advisory level, debunking a widely repeated myth, and flagging a visa system that sellers still advertised but that had likely been discontinued.
Performance
Bug: every data page failed the layout-shift budget
Every page that loaded data failed Google's layout-shift (CLS) budget: 0.245 on mobile and 0.322 on desktop. The footer was visibly jumping down the page after load.
The cause: a single top-level loading file. In Next.js, it wraps the whole page in an automatic loading boundary. The first paint was an empty page with the footer near the top, and when the content streamed in, the footer was shoved thousands of pixels down. That one shift was the entire score. It isn't in the Next.js documentation; I found it by reading the raw HTML.
The fix: remove that file and add smaller loading boundaries around each section that fetches data, each with a placeholder sized like the real content. Layout shift went to effectively zero on every page I measured.
On a local production build, Lighthouse performance scores were 81 to 96 on mobile and 96 to 100 on desktop, depending on the page.
Bug: maps rendered blank
Maps sometimes rendered as an empty box. Mapbox's newer styles default to a globe view, which draws nothing if the map is created before its container has a size. Forcing a flat projection and keeping the map in sync with its container's size fixed it.
Security, inside the database
With Supabase, security rules live in Postgres. Two lessons stand out.
Closing a privilege-escalation hole before launch
Row-level security decides which rows a user can change, not which columns. A normal "users can edit their own profile" rule would have let anyone make themselves an admin by editing their is_admin field directly through the API. I caught it while designing the blog and locked every table down to the specific columns users may change. For example, authors can set who wrote a post when it's created but can never change it afterwards.
Bug: likes that "unliked" themselves
In production, view counts stayed at zero, and a like would disappear about a second after clicking.
The cause: the database functions that update like and view counts ran with the visitor's permissions. Because of the column rules above, visitors correctly can't change those counts, so the update failed and rolled back the like itself, and the screen reverted.
The fix: run those two functions with narrowly scoped elevated permissions, protected against a known attack on that setting, then recalculate every count from the source data. The fix sat exactly where two security measures met: the column rules were doing their job, and the counters needed a precise exception.
Engagement had other careful details too. Logged-out readers can like and view posts through an anonymous cookie, with rules that stop one visitor from claiming another's identity. Private impression counts live in their own table, because a public post exposes all of its columns.
Other surprises
- The sitemap listed 29 dataset pages instead of 1,087. Supabase returns at most 1,000 rows per request, so the sitemap only saw the first page of data. Paging through the data fixed it, and adding a stable sort order stopped pages from overlapping or skipping rows.
- The free database went to sleep. Supabase's free tier pauses after a week without activity, which blanked pages and shrank the sitemap. An uptime monitor pinging a health endpoint every five minutes now keeps it awake and watches for downtime.
How I work
I use AI tools throughout: to plan work, to review changes, and to verify them. I own the architecture, the decisions, the debugging, and what ships, and I properly review any code I didn't write myself before it's merged. On Cusp Africa I also kept a written handoff at the end of every working session, recording what changed, what was decided, and what was verified.
Results
- Took a client's product requirements to a launched product, owning product, design, and engineering.
- 27 indicators and around 40,000 data points from the World Bank, WHO, and UNICEF, refreshed on a schedule.
- About 13,000 destinations across 54 countries, with every country profile hand-curated and fact-checked.
- Layout shift cut from 0.32 to effectively zero across the data pages.
What I'd do differently
- Automated checks on every change. I verified with type checks, linting, builds, and manual checklists at each stage, but nothing ran automatically. A few tests on the data parsers and the HTML sanitiser would pay off quickly.
- Generated database types and a migration tool. I typed the database by hand and applied migrations manually. Both worked, but both needed careful bookkeeping.
- Smaller commits. The first release landed in one very large commit, which makes its history harder to follow.
