Case study
Onward: a trip planner where the map, the calendar and the budget agree
A sample product build by NextWave Systems. Nobody commissioned it. It exists so that the way I build — the boundaries, the failure states, the things a tool has to keep doing after the handoff — can be read in running code rather than taken on trust.
The problem
A multi-stop trip gets planned in three places at once: a spreadsheet of costs, a map tab with a dozen pins, and a notes app holding the actual order of things. None of them know about each other. Move the Kyoto leg a day later and the spreadsheet is still right, the map is still right, and the plan is quietly wrong. The information isn't missing — it is just never in one place long enough to disagree out loud.
Onward puts the stops on a map, the days under the stops, and the money against both, so that a change in one of them is visibly a change in the others.
What it does
Plan a multi-stop trip on the map: stops, days, stays and activities, with the calendar and budget kept honest. Copy the sample itinerary and edit it without signing up.



Watch it
Product walkthrough — not recorded yet
AI and MCP — not recorded yet
AI is a local capability here, not a public one. The in-app assistant needs a model key and an explicit flag, and this deployment sets neither — the endpoints answer 503 for everyone, which the HTTP test suite checks. The MCP server is something you run on your own machine against your own account. Nothing you do on this site is sent to a model.
My role
Sole designer and engineer: the data model, the Postgres work, the API, the interface, the design system, the tests and the operational tooling. The starting point was an earlier prototype of mine on a different stack — a single-process app over a local SQLite file — and most of the work was not translation but the questions that a shared, multi-user, publicly reachable database asks and a local file never does: who owns this row, what happens when two people write it, and what does this cost when a stranger clicks it a thousand times.
The constraints that shaped it
It has to cost nothing to run
No paid map subscription, no automatic overage. Tiles are OpenStreetMap's; search and routing are one LocationIQ free plan. The free plan's ceilings are 5,000 requests a day and 60 a minute, so the app has to hold itself under them — and not by trusting each server instance to count its own.
Visitors have to be able to edit without an account
A read-only demo is easy and proves nothing. But letting anyone write means anonymous identities, a lifetime for their data, and a story for what happens when it ends — all without a sign-up form standing between someone and the thing they came to look at.
It shares a database with a different product
Onward and LineView run in one Supabase project, on one login. Neither may be able to read the other's data, and an anonymous Onward visitor must not become a LineView account. That boundary is the first thing a reviewer should check, so it is enforced in the database rather than in the code that happens to query it.
Interface decisions
1.Search is a button, not a keystroke
Typeahead would send a request per keypress against a metered free plan, and results for half a word are rarely useful. You type the place and press Enter. Picking a result attaches the pin; typing a name and saving without picking one is fine too — the server looks it up in the background afterwards, and says so while it does.
2.Dragging is an enhancement, never the only way
Every move has a control: arrows to reorder a stop, a dialog to move an activity to another day or another city, a select to schedule an idea. Drag-and-drop is layered on top for pointers. That is an accessibility requirement, but it is also the only version that works on a phone, which is where half of trip planning actually happens.
3.An estimate is labelled an estimate
Walking and driving legs get real routed distances when a router is configured. Trains, buses, ferries and flights never do — they get a straight-line estimate, and the card says so, every time. Prices are planning figures and the budget card repeats that they are not live quotes. A planner that quietly implies it is quoting you is worse than one that admits what it knows.
4.A pin that is roughly right says it is roughly right
Geocoders return a house number, a street, or an area, and the difference matters when you are deciding whether you can walk it. Onward keeps that precision and puts it on the row: “Approximate: adjust pin”, which is also the button that lets you place it yourself.
Stack
- Frontend
- Next.js 15 App Router, React 19, TypeScript, Tailwind, Leaflet (loaded client-side only)
- Backend
- Postgres in its own schema, reached as an unprivileged role over the Supabase transaction pooler; Supabase Auth for identity
- Providers
- OpenStreetMap tiles; LocationIQ free plan for search and routing, behind a shared database budget
- Quality
- Unit tests on the pure logic, integration tests against real Postgres as the real role, and an HTTP matrix against a running server
The decisions worth your attention
The parts a client should care about, because they are the parts that decide whether a tool like this is still correct in a year, or after somebody else touches it.
1.The tenancy boundary is a role, not a WHERE clause
Onward's tables live in their own Postgres schema, reachable only as an unprivileged role that has no rights at all on LineView's tables, through the transaction pooler. The Data API roles are revoked on the schema, so no API key can touch it. LineView, in turn, refuses anonymous sessions at the database, in its API routes and in its dashboard layout — an Onward sandbox visitor is authenticated, and that is exactly the case a naive `if (!user)` would wave through.
2.One outbound budget, in the database
Serverless means many instances, and a per-process rate limiter under a shared free plan is a fiction. Every outbound provider call takes a slot from one Postgres gate that enforces minimum spacing, a per-minute count and a rolling 24-hour count across every instance and the MCP server. It never sleeps holding a connection: it refuses with a retry-after, and the caller defers or tells the user.
3.Slow lookups can't overwrite newer edits
Geocoding happens after the response, not during it. So a lookup that started before you renamed a place must not land on top of the new name. Each place carries a version that a write bumps; the background runner applies its result only if the version still matches, and otherwise throws the answer away. Without that guard the failure is rare, silent, and impossible to reproduce.
4.Sandboxes expire on a schedule they were born with
An anonymous sandbox lasts 30 days from creation. Reads don't extend it, writes don't extend it, and the database's own constraint says the expiry equals creation plus 720 hours, so no code path can quietly grant more. The session is only ever created on an explicit click, after the terms are on screen — never on page load, and never after a failed auth check, which would strand the trips someone already made.
5.Every mutation takes the trip's lock first
Two tabs, a background job and an MCP client can all write one trip. Each mutation locks that trip's row before it reads anything, so stop positions stay 0..n-1 and there is exactly one travel leg between each consecutive pair — invariants the integration suite asserts after concurrent writers, not just after a single tidy edit.
6.The agent proposes; a person approves; then it writes
`propose_plan` writes nothing. It validates the itinerary, geocodes it, and hands it back with the warnings and the places it could not find. `apply_plan` takes that same plan as an argument and is the only thing that writes. Nothing is pending on the server between the two, so the approval is a real gate rather than a confirmation dialog over an action already half-taken — and because the plan came back through a model, it is re-validated as untrusted input.
What “done” meant
There are no usage numbers to show you, because nobody uses this but me — inventing some would tell you more about my honesty than my engineering. What there is, is the set of things that had to be true before each stage was called finished:
- 165 unit tests over the pure logic — money and date maths, plan normalisation, the colour system's contrast.
- 88 integration tests against a real Postgres, as the same unprivileged role production uses, through the same pooler — including concurrent writers, sandbox expiry and cross-account denial.
- 17 HTTP tests against a running server: a second account gets 404 on seventeen kinds of request against the first account's trip, quota bypass attempts fail, and the public showcase is cacheable, scrubbed and read-only.
- No horizontal scrolling on any page between 320 and 1440 CSS pixels, measured rather than eyeballed, in both themes.
- Typecheck, lint and a production build gate every commit.
The accessibility pass that closed the interface stage found seven real defects, including a map library painting over every dialog, and dialogs dropping keyboard focus onto the page body when they closed. Those are in the repo's history with the reason each one happened, which is the part worth reading.
What it deliberately isn't
It does not book anything and does not pretend to: costs are figures you type, and the app says so next to every total. There are no live prices and no affiliate links. Sandbox trips are not transferable to an account, which is stated before you make one rather than discovered afterwards. The public AI assistant is off. Each of those is a scoping decision, called out rather than hidden.
Have a look
The sample itinerary is open to anyone. Copy it and change it — no account, no email, nothing to cancel.