A travel planner you write as text, with no server to own your trip

My wife plans our trips. She’s a project manager, a professional planner, and it shows: on every longer trip we’ve taken she has had the flights, hotels and Airbnbs researched, booked and organized while I was still wondering what to pack. I mostly show up and carry bags. This trip is bigger than usual, though, and the logistics are more tangled to match. It opens with a work trip, a stint at Globeteam’s Ho Chi Minh City office, and then rolls straight into roughly three months of travelling through Australia and South-East Asia. There are enough moving parts this time that I didn’t want to leave the whole thing on her desk. So I built us something to plan it in together.
It’s called rejs, Danish for a journey.
The plan is the text
rejs is driven by a small plain-text language, because I prefer those ergonomics. Most planning apps bury every edit behind clicks: a modal to add a stop, a dropdown to say how you got there, a couple of fields for the budget, then the same dance again for the next place. I’d rather write the line and move on. A structured text format gives me that, and as a bonus it’s trivial to diff, to paste into a message, and to hand to someone else.
You type, and rejs draws it. On every keystroke it parses what you’ve written and re-renders three views side by side: a summary with per-currency budget totals, a Leaflet map with numbered pins and coloured legs, and a day-by-day timeline. There’s no “save and preview” step and no debounce; parsing and resolving a plan are pure functions, cheap enough to run on every character.
A plan looks like this:
trip "Australia & South-East Asia"
currency: EUR
hop Ho Chi Minh City:
dates: 2026-11-24 .. 2026-12-01
arrive_by: flight
budget: 900
activity: Globeteam office @ 2026-11-25
note: work first, holiday after
hop Sydney:
stay: 7d
arrive_by: flight
travel: 9h 30m
budget: 1080The language grew to fit how my wife and I actually argue about a trip: a hop is a place you stop, with dates or a stay: length, a budget:, how you arrive_by: (flight, train, ferry, car, and so on), and activity: lines that can be pinned to a date. A drive -> Somewhere: block breaks a road day into overnight stops. Anything the parser doesn’t understand, it complains about in place rather than throwing the whole plan away.
The decision the rest of the app hangs off is that the text is the only source of truth. There’s no separate trip model kept in sync behind the editor. When you drag a pin on the map to nudge a location, rejs doesn’t update some hidden object; it writes a coords: line back into the text. “Add hop” appends a well-formed block. Picking a search result writes the choice into the text. Every edit, wherever it comes from, is an edit to the same document, which then runs back through the same parse-and-render pipeline. Nothing can drift, because there’s only ever one thing to be right.

The text is the one source of truth: parse, resolve, render, and every edit writes back into the DSL and re-runs the loop.
One detail I’m quietly pleased with: rejs geocodes place names with Nominatim, OpenStreetMap’s geocoder, and place names are ambiguous (there are a lot of Springfields). Rather than guess silently or ask about every town, it only stops when the top two candidates are both far apart and similarly plausible. For an unambiguous name it just picks. That removed almost all of the “why is my trip in the wrong country” moments without a dialog on every hop.
No backend, on purpose
rejs has no server. There’s no database, no account, no API of its own. The whole thing is static files. Your plans live in your browser’s localStorage, as one autosaved buffer plus named slots you can keep, and the only traffic that leaves the page is the geocoding lookups and the map tiles. Sharing, later, is the one exception.
That’s deliberate. I don’t want to own other people’s travel plans. If someone else picks up rejs to plan their own trip, I don’t want to be the one sitting on a database of where they’re going and when their house is empty. So there’s nowhere for me to keep it. Your plan stays in your browser, or you copy the DSL out and run the app yourself; it’s a small static site, so npm run dev or a docker run gets you your own copy, and you keep planning with me nowhere in the picture.
Sharing was where the no-backend story broke
Then my wife and I wanted to send the plan back and forth.
With no server, the obvious move is to put the entire plan in the link itself. rejs base64-encodes the text of your plan into the URL fragment (the part after the #, which browsers never send to a server), so a shared link carries the whole trip and still touches nobody’s backend on the way. It works, and it keeps the property I care about: the plan is in the URL, not in a database I’d have had to build and babysit.
The trouble is the link is enormous. A real plan is a couple of paragraphs of text, and base64 of a couple of paragraphs is a wall of characters neither of us was going to paste into a message without wincing. I had a working share feature that produced links too ugly to share.
So I needed to turn a long URL into a short one, and that’s a job that needs a server.
The shortener I’d been meaning to build anyway
I didn’t build the shortener for rejs. It had been on my todo list for a while: I’ve got a scattering of small projects that all, eventually, want to hand someone a tidy link, and I’d rather point them at something I run than at a public shortener that sees every link and can change its terms or disappear. rejs was just the first to actually need it, and it already has company: my timeline app now hands out its share links through the same shortener, for the same collaborative reason.
rejs did add one requirement the off-the-shelf shorteners don’t cover, though, and it’s the interesting part. Remember that the plan lives in the URL, so the moment you edit the trip, the URL changes. If I’d shared a short link and then fixed a single date, the short link would still resolve to the old plan. What I wanted was a short link that stays the same while its destination moves: mint a slug once, then repoint that slug at the new plan every time it changes.
rejs pulls that off by tucking the slug back inside the plan. When you first shorten a link, the slug gets written into the fragment alongside the plan (&s=<slug>). Whoever opens that short link now has the slug in their browser too, so if they edit the plan, their copy can update the same link. The link is stable for everyone holding it, and it always resolves to the latest version of the trip that whoever last touched it produced. That’s exactly the shape a shared, editable plan needs, and it’s the sort of thing a fixed-target shortener was never going to give me.
There’s a catch here I should be upfront about, because it cuts against what I wrote earlier about not owning other people’s plans. A shortener stores its targets; that’s the entire job. So the moment you shorten a link, the full long URL, plan and all, lands in a Postgres table I run. That’s why shortening is strictly opt-in. The long link works on its own and never reaches the server, since browsers keep the fragment to themselves. If you press the shorten button, you’re trusting me with that one string. If you don’t, I never see your trip.

The short link stays stable while its target moves: POST to mint a slug, PUT to repoint it, and anyone holding the link can keep it pointing at the latest plan.
The shortener, kept deliberately small
That service lives at s.lvang.dev. It got its own short domain for the obvious reason: a shortener hosted at something.lillevang.dev would hand you a longer string than the one you started with. The repo itself isn’t public, but the shape of it is simple enough to describe.
It’s a small Go program, and I kept it boring on purpose: standard-library net/http with Go’s method-and-pattern routing, a single Postgres table, no web framework and no Redis. Four routes carry the whole thing:
POST /creates a link and returns a slug.GET /{slug}302-redirects to the target.PUT /{slug}repoints an existing slug, the feature the rejs use case is built around.GET /healthzfor the liveness check.
Slugs are seven characters of base62 from crypto/rand, so they’re not sequential and you can’t enumerate everyone’s links by counting. Collisions are handled by inserting optimistically and regenerating only if Postgres rejects the row for a duplicate key; there’s no “check if it exists, then insert” gap for two requests to race through.
What a public shortener invites
The part that took the most thought is that this service accepts writes from a browser with no login. rejs is untrusted JavaScript running on someone else’s machine, and it POSTs straight to the shortener. Before opening that up to the internet, it’s worth being clear-eyed about what a write-anything shortener is good for in the wrong hands.
The classic abuse is link laundering. A phishing mail with a raw suspicious URL looks like what it is; the same destination behind s.lvang.dev/x7Kq2Fp borrows whatever reputation my domain has and gives filters less to match on (OWASP’s page on unvalidated redirects describes the pattern). Add bulk spam, and the plain fact that an open write endpoint is a free place to script junk into someone else’s database, and none of it needs the attacker to care about me. I run this expecting it to be found by scanners, not hoping it won’t be.
And the bill lands on me either way. The day my domain shows up in someone’s phishing campaign is the day it lands on blocklists, and every legitimate link I’ve handed out dies with it, my wife’s trip plan included.
Two properties do most of the mitigating, and I can share them because both are observable from the outside anyway. The service is not a general-purpose shortener: it refuses to mint a link that doesn’t point back at one of my own applications, which removes the laundering value outright, since an attacker can’t aim my domain at their server. And abusive write traffic gets shed cheaply and early, before it costs the service any real work. Past that, the details stay private, deliberately: the repo is closed, and I see no reason to publish exactly what the fences check.
The seam that never breaks
The rule I held the shortener to is that it’s a convenience and never a dependency. Sharing a plan has to keep working even when the shortener is down, misconfigured, slow, or blocked by CORS.
So every call into it is fail-soft. A disabled integration, a network error, a four-second timeout, a rejected preflight, a malformed response: all of them fall back to the long, self-contained URL. You still get a link that works; it’s just the ugly one. The plan is always sitting in the fragment regardless, so the shortener’s only job is to make an already-working link prettier. If it’s having a bad day, the worst that happens is your link gets long again. That same seam is my out if the abuse ever wins: take the service offline, sharing drops back to long links, and nothing that matters breaks while I clean up.
What’s not there, and what I’d do next
rejs is at 0.1.0, and it shows in the honest gaps. The shortener keeps no per-link analytics; there’s deliberately nowhere in the database to put a click count. Link expiry is enforced in code, not swept up by a job, so expired links stop resolving but their rows just sit there.
Closing that sharing-section catch is what I want to do next. The share button should warn that shortening sends your plan my way before you press it. Shared links should expire, and expiry has to actually delete the row or it isn’t worth much. The one I like best is a password on share: encrypt the plan in the browser first, so the link only opens for someone who types the same password. Then the database holds ciphertext, and the trust I asked for earlier shrinks to almost nothing, because the string I’m keeping is one I can’t read.
The feature you’d expect next is live collaborative editing, with my wife and me on the same plan at the same moment. That’s also the one feature I’m in no hurry to build, because the moment two browsers have to agree on a shared plan, I need real state on a server, and I’ve handed back the exact property that made this worth building. Passing a link back and forth is enough for the two of us. For now the trip lives in a string we can both read, copy, and run ourselves, and the only server anywhere near it is one I can lose without losing the trip. That’s the version I wanted.