Home / Portfolio / Building Empty Leg List

Portfolio · Our Projects

Building Empty Leg List

Radu Balas Radu Balas|Aug 27, 2026|Figures counted from the live build
106static HTML pages, no framework
789upcoming flights on the board
3xboard refreshed per day
8social networks posted to

106 static HTML pages, no framework, no build step. A Postgres board refreshed three times a day. And a pipeline that turns each flight into a short film and posts it to eight networks without anyone opening an editor.

emptylegslist.com is an index of empty leg flights: the one way sectors private jets fly with nobody on board, on their way home or out to collect a client. The flights exist whether or not anyone books them. The hard part was never the aviation. It was that the listings are scattered across dozens of operator websites and expire within days.

The Empty Leg List mark: a cabin window with the aircraft cutting through it.
The Empty Leg List mark: a cabin window with the aircraft cutting through it.

What it is made of

There is no React, no Next.js, no bundler and no CI pipeline. Every page is HTML that a browser can open from disk. Vercel serves the repository root with clean URLs, and a push to main is the deploy.

That decision looks old fashioned and it has paid for itself repeatedly. Pages render before any JavaScript runs. A broken script on the city pages cannot take down the aircraft pages. There is no dependency tree to keep patched, and a change can be made, checked and shipped in the time a framework would take to install.

The dynamic half lives in Supabase. One table holds the board. The browser reads it directly over PostgREST with a publishable key, read only. Everything that writes goes through Deno edge functions with the service role, so lead capture, contact messages, member sign-ups and outbound alerts never touch a key that reaches the client.

The shape of the site

24 city pages  |  45 route pages  |  30 aircraft pages  |  1 live board

Each city page opens with a plain-language read of what is on the board for that city right now, grouped by aircraft and sector, then the filterable list. Each route page shows the corridor in both directions and draws it on a Leaflet map with a great-circle arc rather than a straight line, because a straight line on a Mercator projection is a lie about how the aircraft flies.

The video pipeline

Every listing is a small story: a route, an aircraft, a date, a price. That is enough for a fifteen second vertical film, and there are hundreds of them a week.

So the pipeline builds them. Eligible flights come out of Postgres. A script becomes a voice track through ElevenLabs. Remotion renders the film in React, with the route drawn from real coordinates using d3-geo over Natural Earth land data, the destination photograph behind the map, and a cover frame composed at frame zero so the thumbnail is never a black square. The file goes to Supabase Storage, then to Postoria, then out to Facebook, Instagram, YouTube, TikTok, X, LinkedIn, Threads and Pinterest on a three hour cadence.

flights in Postgres
  -> eligibility filter (price, seats, aircraft, flight time, known cities)
  -> voice script -> ElevenLabs
  -> Remotion render (cover, map, destination, price)
  -> Supabase Storage
  -> Postoria -> 8 networks, one post every 3 hours

Cities without a photograph do not borrow one from somewhere else. They get a navy monogram panel built from the city's initial and the route arcs. It is a small rule with a large effect: nothing on the site is ever a picture of a different place.

What happened when it started publishing

For the first week after the pages went live, traffic was flat. It changed on the day the video pipeline began posting, and it changed by a multiple rather than a margin.

The more interesting part is where it came from. Facebook is now the largest single referrer, ahead of search. That is what you would expect this early: the search rankings need months, the content does not. Chat assistants already appear in the referrer list too, which is worth watching.

The lesson is unglamorous. The pipeline was not built to grow traffic. It was built because a listing is a story that takes fifteen seconds to tell and nobody was telling it. Traffic followed because the flights are genuinely interesting, not because anything was optimised for it.

Three bugs worth the retelling

Venice matched Nice

City matching used a substring test:

function has(v, t) { return String(v || "").toLowerCase().indexOf(t) !== -1; }

"Venice".indexOf("nice") is 2. So a London to Venice flight appeared as the only live flight on the London to Nice page, and the Nice city page counted flights to Venice as flights to Nice. It had been live for weeks and looked like a data problem rather than a string problem.

The fix was a match that has to start at a word boundary, and that folds accents while it is there. Ninety four replacements across sixty nine pages. The accent folding was an accident that paid: Málaga had never matched malaga, so four flights had been invisible on their own city page.

Last checked was measuring the wrong thing

Every route page said when prices were last checked. The number came from the freshest timestamp among the flights matched on that route. A flight only gets a new timestamp when something about it changes, so a page could say last checked 43 hours ago while the whole board had been rescanned three hours earlier.

The number was true about one flight and false about the sentence it sat in. It now reads from the whole result set, and the sentence changed to match what it actually measures.

Two runs, every video posted twice

A manual run started while the scheduled one was already going. Both read the same empty log, both found the same three flights, both posted. Nine posts existed where there should have been five, and the local log only knew about the ones written last.

Two fixes, not one. A lock file, so two runs cannot overlap. And deduplication on route plus departure date read back from the publishing API before anything is created, rather than on a database row id held locally, so a repeat is caught however it got there.

The pattern in all three: the code was doing exactly what it said, and what it said was subtly not what anyone meant. None of them threw an error. All three were found by comparing what the page claimed against what the database actually held.

Things worth stealing

Static first, dynamic where it earns it. The list has to be live. The page around it does not.

Say what you do not know. Where an operator publishes no price, the listing says so. Inventing a plausible number would be easy and would poison everything else on the page.

Never substitute a picture. A generic sunset standing in for three different cities costs nothing to ship and quietly tells the visitor the whole site is approximate.

Automate the boring end completely. The video pipeline is not clever. It is a filter, a text-to-speech call, a render and three API calls. What makes it useful is that nobody has to remember to run it.

100 things that are actually built

Every line below exists and is live, today. Counted honestly, including the small ones, because the small ones are most of the work.

The site itself

The data layer

Search and filtering

Maps

Photographs

Leads and members

The video pipeline

Brand and publishing

The identity the films and the social accounts are built from: primary lockup, dark and light versions, social icon and palette.
The identity the films and the social accounts are built from: primary lockup, dark and light versions, social icon and palette.
Empty Leg List is published by Publishing Office SRL, the team behind The Flying Engineer. The index is at emptylegslist.com. Operators who want their empty legs listed can write to open@emptylegslist.com.

The index is live

Every flight described here is on the board right now, refreshed three times a day.

Visit emptylegslist.com →

Want the same machine for your own inventory?

Static pages where they earn it, a live database where they do not, and an automated pipeline that publishes without anyone opening an editor. Tell us what you have.

Talk to us →