Case study

Nomad TravelGuide: one app, built alone

How a trip planner turned into an app with a backend, moderation and legal texts — and the three technical decisions that made the difference.

Scale
131 Dart files, 55,373 lines, 55 screens
Backend
23 Cloud Functions on Firebase
Languages
1,176 translation keys per language
Timeline
First commit February 2026, on the App Store since July 2026

The starting point

Planning a trip usually means bouncing between a search engine, maps, booking sites and a notes document. Nomad puts the planning, the content and the people currently travelling the same route into one app: a planner that turns dates, countries and modes of transport into a day-by-day itinerary, a map of community spots, chat, quests and a friends book. Written in Flutter, iOS and Android from one codebase, with a Firebase backend.

I built it alone, alongside technical college: the idea, the architecture, the code, the store presence and the legal texts.

The architecture in outline

The client holds the interface and everything that has to work without a connection. Firestore persistence is explicitly enabled with an unlimited cache, so every listener serves from the local cache first and refreshes from the server afterwards. That was less an optimisation than a necessity: for users far from the European region the first round trip to the server is clearly noticeable, and a travel app is by definition used away from home.

The server holds everything the client must not decide. The 23 Cloud Functions cover moderation, push triggers, cleanup jobs, shared itineraries, the search index, the usage counter, trip reminders and account deletion. The line between the two is not a matter of taste: as soon as a decision enforces a right — who may read, who may delete, what happens when something is reported — it belongs on the server, because a client can be modified and a security rule set cannot.

The Firestore rules state this most plainly. The reports collection, for instance, is entirely closed to the client: only an administrator may read it, and nobody may write to it. A report reaches the database only through a Cloud Function that checks what it is doing first.

Offline capability ends exactly where those functions begin: the AI chat, sharing an itinerary, reporting content and answering a ping all need a connection. Everything else — reading, planning, writing — carries on and is synchronised once the connection returns.

Three decisions

01

The itinerary was the hard part

Turning dates, countries, modes of transport and a handful of chosen activities into a plan sounds like distribution. The obvious approach — place activities onto days in order — falls apart in four places, and I walked into every one of them.

First, durations arrive as free text: “3 days", “2-3 days", “90 minutes". A parser that only understands hours reads those 90 minutes as 90 hours and blocks out an entire week. Community spots therefore supply their duration numerically, never as a string. Second, multi-day activities land on a single day in the normal pass, like an all-day activity — they have to be stretched across the following days afterwards, displacing whatever was already scheduled there. Third, a day spent crossing a border belongs to the journey, not to the sights. And fourth, travel time between two stops is not a rounding error; it is the reason plans fail in practice.

So what I built is not a distributor but three consecutive passes: the first fills days against a time budget, the second stretches multi-day activities across the following days, and the third redistributes everything that fell out onto empty days in the same country — provided the budget allows it. Travel times are fetched in batches from the Google Directions API beforehand; if that fails, the generator falls back to straight-line distance rather than giving up.

The cost: three passes instead of one, plus a budget of API calls. The gain: a generator that reports what it left out. A plan that hides the fact that three activities no longer fitted is not a plan — it is a disappointment on a delay.

02

A search index instead of a prefix search

Firestore has no “contains". The usual shortcut is a range query over a lower-cased display name: it finds “daniel_w" when someone types “daniel" — but it will never find “nomad_daniel", because the match is not at the start.

Instead, a trigger maintains a searchTokens field for every user. The name is normalised, split at every non-letter, and each word produces its prefixes from three to fifteen characters, plus the concatenated full name. Umlauts are replaced before Unicode decomposition, otherwise “Müller" would be unfindable under “mueller"; letters with a stroke such as ł or đ need their own rules, because decomposition does not resolve them. The whole thing is capped at 120 entries — “Daniel Wulf" produces ten.

The cost: the trigger runs on every write to the user document, even when the name has not changed, and each token costs an index entry. The gain: results independent of spelling, without adding another service — one I would have to pay for, operate, and declare as a data recipient in the privacy policy.

03

A guard rail against a mistake that never happened

When an administrator handles a report, they call deleteContent with the path of the reported content. For a reported profile that path is users/{uid} — and a recursive delete on it would have removed the entire user document along with all its subcollections. Irreversibly, and without the associated auth account: what would have remained is an account that can still log in and contains nothing.

The function therefore checks three things before touching anything: the path must point to a document rather than a collection, its root must appear on a list of deletable areas, and anything else is rejected with an error that names the right tools instead — reset profile content, suspend the user, ban the user.

The cost: three functions instead of one, and more code. The gain: the mistake cannot happen — not when tired, not in a hurry, and not in six months when I have forgotten that a profile report looks different from a reported comment.

User-generated content is an operation, not a feature

The moment users can upload photos and text, an app stops being a product and becomes an operation — with reports, review and liability.

A report does not go into the database but to a Cloud Function. It creates exactly one document per reported item, collects reporters as a set — so reporting the same thing twice changes nothing — and takes an evidence copy of the text and image, because the content may well be deleted by the time anyone reviews it. From three distinct reporters onwards the content is hidden automatically, but only where hiding achieves something: spots, comments and chat messages. For profiles and groups the case is flagged as urgent instead, so it moves up the review list. At the end there are three possible outcomes: deleted, dismissed — in which case the hiding is undone — or action against the author, either a temporary suspension or a ban.

Every action is written to a log recording who did what, when and why. The affected user receives the reasoning in a subcollection of their own that they can read and mark as read, but not delete: it is their justification, and nobody should be able to take it away from them.

Data protection becomes very concrete in two places. The last shared location is deliberately not stored in the user document, because that document is readable by anyone signed in — any account could read any user's position. It lives in a separate subcollection that only the owner and confirmed connections may read. The promise made in the privacy policy is therefore enforced on the server, not merely in the interface. And the usage statistics accept no user identifier at all: authentication serves only as an abuse brake, only keys from a fixed list are counted, and the increments are capped.

Account deletion is the most demanding part of it — required by Art. 17 GDPR, by Apple's guideline 5.1.1(v) and by Google's account deletion policy. It clears eleven subcollections, spots, comments, conversations, connection requests, pings, challenges and profile media. The main thing I learned there was that the order matters. First check whether the auth account can be deleted at all, then remove the data, and only then delete the account itself. The other way round, it could happen that all data was gone but deleting the account failed because the last sign-in was too long ago — leaving an account that could still log in and held nothing at all.

From code to store

Five months lie between the first commit in February 2026 and approval in July 2026, with my technical college project running in parallel. App Store submission took three attempts: two rejections over cosmetic issues, and one because Sign in with Apple did not work for the reviewer. That last one taught me the most: the sign-in path worked on my own devices but not under review conditions — the kind of fault you only find once you stop trusting your own test device.

One thing that saved time is usually tacked on at the end: the privacy policy was written against the actual code, not against a template. Anyone who first works out what data their app touches while filling in the app privacy report will end up explaining things to the review that they do not fully understand themselves.

What I take away

Two things. I would no longer pull images and graphics from Firebase Storage; anything that is a fixed part of the product would ship with the app — that saves round trips and cost, and makes the app more complete offline. And the habit that comes from four years on a production floor: a fault is fixed when the machine runs again, not when the cause has been found. A search index that finds half the names is not half a success.

Nomad TravelGuide is the work of one person, Daniel Wulf. For project enquiries and collaboration: danielwulf.dev