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.
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.
"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
106 static HTML pages, served straight from the repository root.
No framework, no bundler, no build step and no CI pipeline.
Deployed on Vercel with clean URLs; a push to main is the release.
24 city pages, each opening with a plain-language read of what is live for that city.
45 route pages, one per corridor, covering both directions.
A page for every aircraft type that keeps appearing on the board.
A live board on the home page with nine filters and three sort orders.
An enquiry page that carries the flight's details through from the listing.
A page for operators explaining exactly what listing involves.
About, contact, terms, privacy and cookie pages, written rather than generated.
A member area with its own access gate.
A sitemap and robots file kept in step with the pages that exist.
A full icon set: favicon, apple touch icon and the PWA sizes.
A mega menu with hover intent, so the diagonal move to a link is not a race.
A mobile navigation that locks the page behind it instead of scrolling under.
Structured data on the route pages: breadcrumbs and a real FAQ block.
The data layer
A Postgres board on Supabase holding every live flight.
A staging table, so a bad import cannot corrupt what is being served.
Price history, so a change can be seen rather than guessed at.
Weekly operational snapshots of the board's size and health.
The browser reads the board directly over PostgREST with a publishable, read-only key.
Eight Deno edge functions covering listings, leads, contact, alerts, the member area and operations.
Row level security with no anonymous policies on every table that holds a person's details.
The board is updated three times a day.
789 upcoming flights on the board as this was written.
70 distinct aircraft types represented.
Prices parsed with their currency and kept as the operator wrote them.
Where an operator publishes no price, the listing says so instead of inventing one.
Search and filtering
From and To fields that accept a city, an airport code or a country.
Country search backed by 124 cities mapped to their ISO country, from the public airport dataset.
Country matching is exact, so a city can never be swallowed by a country search.
Matching folds accents, so Malaga finds Málaga.
Matching starts at a word boundary, so Nice no longer finds Venice.
Alias folding for the spellings operators actually use: London Luton, Kortrijk-Wevelgem, Genova.
Suggestion lists built from the live data, with a count next to each entry.
Filters for date, aircraft type, seat count, region, month and price band.
One-tap presets for newly listed, under ten thousand, light jets and larger cabins.
Sort by departure date, by price, or by what arrived most recently.
A filter summary that says how many flights match before you scroll.
Every filter reflected in the city page's opening paragraph, so the words match the list.
An honest empty state that says how many flights leave and arrive at each end instead.
Freshness shown as a real interval from the last update, on every route page.
Maps
A Leaflet map on every route page, drawn from real airport coordinates.
The corridor drawn as a great circle, because a straight line on Mercator is a lie about the flight.
Labelled pins at both ends, sized to be readable on a phone.
A 4:5 frame, which suits the mostly north-south corridors far better than a wide one.
A map on every city page as well, showing the corridors that are actually open.
Coordinates taken from the public airport dataset, never typed from memory.
The caption under each map set to the map's own width, so the two line up.
Photographs
A hero photograph on all 24 city pages.
An image on all 45 route pages.
Every page has its own crop, so no two pages share a file.
Every file re-encoded from pixels, which discards the agency metadata entirely.
No IPTC block, no XMP packet and no agency name anywhere in the bytes, verified per file.
A written description, author and copyright line in each file's EXIF.
The airport's GPS position written into every image.
Alt text that describes what is actually in the photograph, not the page it sits on.
Cities with no photograph get a monogram panel, never a picture of somewhere else.
Heroes at 2400 by 1040, sized to the band they sit in.
Social crops in three shapes, portrait, square and link card, for 26 cities.
Vertical stills for the video pipeline, one for each of 64 cities.
og:image and twitter:card correct on every page that has a picture.
Leads and members
An enquiry form that writes to the database through an edge function, never through the browser's key.
A contact form on the same footing.
A member gate that captures consent with the exact wording shown at the time.
Route alerts, in both directions, from a one-field form on every route page.
An outbound alert job that watches the board and mails the people waiting on a corridor.
An operations page listing every enquiry, message, member and alert in one view.
That page is protected by a password stored as a SHA-256 hash, never as a key in the browser.
A wrong password is slowed down deliberately before it is refused.
Enquiries and messages can be marked handled and reopened.
The page is noindex, no-referrer and linked from nowhere.
The video pipeline
One short vertical film per flight, rendered in React with Remotion.
A cover frame composed at frame zero, so the thumbnail is never a black square.
A map scene drawing the route from real coordinates over Natural Earth land data.
The destination photograph behind the map, dimmed so the route still leads.
A destination scene and a price scene with a counting odometer.
Brand type and colour throughout: Playfair and Raleway, navy and gold.
Voice-over generated per flight and cached, so nothing is paid for twice.
Files hosted on Supabase Storage and handed to the publisher by URL.
Published to eight networks: Facebook, Instagram, YouTube, TikTok, X, LinkedIn, Threads and Pinterest.
One post every three hours, only between eight in the morning and ten at night.
A lock file, so two runs can never overlap and post everything twice.
Deduplication on route and departure date, read back from the publishing API before anything is created.
A dry run mode that shows exactly what would be posted without posting it.
Captions on short lines with air between them, not a block of prose.
Caption length measured the way X measures it, counting any link as 23 characters.
If a caption runs long the hashtags go first, then the disclaimer. Never the flight's facts.
A published-video index kept in Storage and rebuilt on every run.
Route pages show the films made for that corridor, loaded on click from youtube-nocookie.
The whole thing runs from the machine's own scheduler, daily, unattended.
A job runner alongside it that picks work up from a folder every two minutes.
Brand and publishing
The identity the films and the social accounts are built from: primary lockup, dark and light versions, social icon and palette.
A logo lockup, mark and full icon set.
Profile pictures and channel headers built to the same design system as the films.
Eight social accounts connected and posting.
A bio written for each network's own character limit.
A list of words the brand does not use, applied to every field and every caption.
British spelling everywhere, including autumn.
Prices always described as the operator's figure for the whole aircraft, never per seat.
The listing-service position stated on every page and in every caption.
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.
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.