Retrofitting Language Into Our Stack
Vishnu Reddy · Senior Software Engineer ·
Our first post promised we would tell the story of how we added support for five more languages. This is that story.
The guest-facing ResortPass mobile and web experience now supports Spanish, French, Italian, German, and Portuguese alongside English. Adding another language is just a configuration change and a pass through the translation pipeline (all five are Latin script and left to right, so a non-Latin script or a right-to-left language would test that claim in ways we have not). We shipped it faster than we scoped it, with no rollbacks, and with several pivots along the way that cost us almost no rework.
It was obvious from the first week that the project was large. What surprised us is that it presented fewer problems than we expected. Not because the work was easy, but because of how we sequenced it.
Four engineers led the work: Asi Farran, Hammad Shahid, Rodrigo Martins, and me. Two of us, me included, had joined ResortPass only weeks earlier. We are growing our engineering team, deliberate about both who joins and the culture we are building together. The project became an early test of how we want to work: in small steps, deciding with evidence, and keeping releases a two-way door.
Why Language Came First
We are a marketplace, and supply is half the battle. We generate demand for hotel experiences, and we grow by having more places worth booking.
Our product is connecting guests to highly amenitized resorts and hotels for a day of rest and recharge. We list properties in more than thirty countries, but the large majority of our bookings are in the United States, and there we already work with much of the supply that fits: for many of the best pools, spas, and resorts we are the only place to book a day. That leaves little low-hanging fruit in our strongest market, so the biggest opportunities are in regions where we have no footprint at all. Southern Europe alone has roughly three times the density of four- and five-star hotels with outdoor pools that the United States does.
There is a domestic case too. About half of our guests are travelers rather than locals, and many of them would rather browse in another language. So would plenty of residents: a guest booking a pool day twenty minutes from home has the same reason to want their own language as one who flew in.
Both of those are growth arguments. There is a simpler one. Until now, a guest who does not read English comfortably had to work harder than everyone else to book with us, and nobody should have to translate a checkout flow in their head to spend an afternoon by a pool. Making the product work for them is worth doing whether or not it shows up in the numbers.
So language is not a side quest. It is table stakes for operating in a new market, along with currency and compliance.
Where We Started
Nothing in our stack was language-aware.
Our web frontend is a Next.js application, and every string a guest reads was written directly into a component. Our backend is Rails, and it serves both static copy from the code and dynamic content from Postgres, where every record held exactly one copy of its content. A system built with more than one language in mind would have looked different from the start. Ours grew up assuming one source of truth per piece of content, and that assumption cost nothing until language became a second dimension every piece of content had to vary along.
The harder part was what was already there. In several places our content selection branched on conditions: show this copy when that is true, otherwise show the other. Adding language on top does not add a case, it multiplies them. Every existing branch acquires a language axis, and the number of paths that decide what a guest reads grows accordingly.
If we could start over we would not have introduced most of that branching. Retrofitting language into it meant working around decisions made long before translation was on our roadmap, and the particular shape of what we hit came from how our own records were modeled. We went in expecting to find something ugly, and we did.
Our original plan was to adopt Mobility, a well established open-source translation storage library for Rails, and let it own the base layer. A few weeks in it was clear that the way it resolves translations did not fit the way our content is modeled, so we backed out and built our own translation storage. That was the first pivot, and the one that mattered.
Language as a Presentational Concern
Most translation libraries work from the inside out. You ask the query layer for a product in Portuguese and it resolves the translation for you. That is convenient, and it means the query layer has to know about language. Where translations live in their own tables, that shows up as joins and as eager-loading you have to remember, a tradeoff Mobility's own documentation is candid about. Chris Salzberg, who wrote Mobility, has a clear survey of these storage strategies and what each one costs. It shaped how we thought about the problem, even though we ended up somewhere he does not go.
Having backed out of Mobility, we could have built a bespoke version of the same idea. We did the opposite. We treat language as a presentational concern and resolve it only at the boundary of the application, where an API response is assembled. It bought us three things.
Translation is opt-in. Most code that loads a product does not care about its description; it needs a price, an availability window, an identifier. Because resolution happens where a response is assembled, the only paths that touch translations are the ones that actually render copy.
We could target the work. Instead of one change to a shared query path, we wrote translation schemas for the specific payloads serving the pages we wanted translated. That let us translate one payload at a time, in the order the business cared about, instead of committing to all of them at once.
And it kept the regression surface small. Changing our product queries reaches a wide swath of our API surface, and we would have had to cover all of that ground before shipping with any confidence. Changing one payload reaches only whatever renders that payload. This is the single biggest reason we could move quickly and safely at once, and the decision we would most confidently make again.
It is not free: the boundary approach means a translation schema per endpoint, which is real work that repeats. We had to choose one or the other, and this approach is what let us ship.
Underneath, the dynamic content from Postgres lives in a single translations table where each record is keyed by locale. The static copy Rails serves from its own code was handled separately, in message catalog files rather than in the database, so there are two mechanisms on the backend: catalog files for the strings that live in code, and the translations table for the content that lives in Postgres.
The language itself arrives as a prefix on the URL path, with English unprefixed and the other five behind a path segment. Guests never talk to Rails directly, so our Next.js server takes it from there and sets the Accept-Language header on every call it makes. That header is how Rails knows which language to serve. One contract, in one place.
Strings on the Frontend
Every string in the Next.js app was static. Nothing rendered copy out of a database call, which made extraction mechanical, and there were about fourteen hundred of them. They now live in JSON message catalog files in ICU format, authored in English (Rails and our Flutter app each have the same arrangement for their own static strings, in the file format and message syntax their ecosystems expect). English is the source of truth, and when the English catalog file changes the new and changed strings go out to a translation management service, which returns the other five catalogs.
What made this harder than a clean extraction was a migration already under way. We were moving from the Pages Router to the App Router at the same time, and still are, and we ran the internationalization work in parallel rather than let either block the other. next-intl supports both routers, so the price was configuration and education rather than architecture: we had to know which import was legal where, and a server-only helper pulled into a client component fails in a way the error message does not explain.
Translating It Ourselves
The catalogs go through a translation management service, which is the shape those products are built around: files of strings, checked into a repository. Database content is a different problem, and we did not find an off-the-shelf equivalent for it. What we did look at was a cloud machine translation API, which returns a good translation from a simple call and leaves everything around it to us. At our volume the pricing did not work.
We already run on AWS, where its model hosting service gives us a menu of large language models at different price points, so we ran a bake-off. We assembled representative strings, both plain text and markdown, and ran them through several LLMs, measuring cost per string alongside quality. Quality was judged by human reviewers who speak the languages.
A mid-tier general-purpose model was the sweet spot. The largest, most capable model was overkill for the task, and the cheaper alternatives were not good enough. Against the cloud translation API, the output was at least as good.
So against the cloud translation API we had priced out, our own pipeline came out both cheaper and better, which is not the trade we expected to find, and building it was less work than we feared.
No model gets every string right, so the pipeline cannot be the last word on any string. Letting a person correct one, and guaranteeing the next run respects the correction rather than overwriting it, is a future extension of this work.
An LLM Call Is Not a Pipeline
Getting a good translation out of an LLM turned out to be the easy part. What we underestimated was everything around the call, and we ended up building four things we had not planned for.
Calls that fail. A request can be throttled, time out, or come back as an error, and any of those can land in the middle of a batch. So the pipeline has to retry, know when to stop retrying and leave a struggling service alone, and pick up whatever a crashed run left behind.
Output that is wrong. A call can also succeed and return something we cannot use, which is worse because nothing errors. So the pipeline validates its own output before storing it, checking that what a translation must not alter came through intact. Clock times are one example: "8:00pm" coming back as "8:00" reads as a perfectly good translation and quietly moves closing time by twelve hours. Designed from scratch, opening hours would not be sitting inside prose at all. They would be structured data, formatted per locale at render time. Enough of our copy has times written into it that the pipeline has to defend them where they sit.
Deduplication. The same copy turns up on many different records: a title like "Day Pass" repeats across hundreds of properties. Translating it once per record pays many times over for one result, and because the model is not deterministic those results will not all match. The same title comes back worded three slightly different ways, which a guest sees the moment two of those properties sit next to each other in a list. So identical source text is translated once and that one result is reused everywhere it appears. Our first version had no memory of what it had already done.
And telemetry. A pipeline that runs on a schedule and writes to the database needs to be legible from the outside: what it processed, what it skipped, what it could not translate, and whether it is keeping up.
Build all four and the shape of it starts to look familiar. These are the responsibilities a translation management service takes on, met here for our own narrow case rather than as a product. That is a reasonable thing to own and we would do it again. It is just a good deal more than a prompt and an API call.
What Made It Safe
We shipped this across a live system without a rollback. A few practices got us there, and none of them are specific to translation.
Feature flags. Translation landed in the codebase well before any guest saw it, so shipping the code and releasing the feature stayed separate decisions, and the second one was reversible in seconds.
Small commits with narrow purposes. Every change did one thing. That makes a revert a small delta rather than an untangling, and it makes bisecting for where a bug entered fast enough to be worth doing.
Encapsulation. We picked features and translated them whole, keeping the translation inside the function that does the work rather than spraying conditionals across its call sites. Callers kept doing exactly what they did. That let us verify one feature properly and then move to the next the same way, and it left behind small functions the next person can extend rather than a pattern they have to reproduce.
Tests alongside every extraction PR, so a regression months from now is caught by CI and not by a guest. And lint rules that reject a hardcoded user-facing string locally, so a forgotten catalog entry surfaces before the commit rather than in CI.
What We Learned
Postpone decisions until you have to make them. We deliberately left options open and, each time we had to make a call, made only the part of it required right then. We pivoted on the library, on the storage, and on the layer where language resolves, and lost almost no work. Every pivot was cheap because we had not yet built the things that would have depended on the discarded choice.
Postponing decisions was not a principle we set out with. It emerged from the work, and it worked well enough that we kept it. Deciding late means deciding with the most information we will ever have, it makes pivots cheap, and it shortens the gap between doing something and finding out whether it was right. We apply it deliberately now, on work that has nothing to do with translation. We did not expect a translation project to be where we worked that out.
Prefer declarative over imperative. The translation schemas are one example. Each one is data naming which fields of a payload can be translated. None of them is code that goes and translates something. So adding a page meant adding an entry, and reviewing that change meant reading a list instead of tracing what a function did. A wrong entry in a list is a much easier mistake to spot than a wrong step in a procedure.
The thing we got most wrong was not technical. We changed how strings are written in this codebase, and how links are generated, and never formally told the rest of the team. Features shipped afterward with hardcoded strings and hand-built links, and we have spent time since fixing bugs a short session and a written note would have prevented. When you change a convention, the change is not done when the code merges. It is done when the people who will write the next line know about it.
The decision we are least sure about is buying rather than building. Our frontend strings went through a managed translation service, and part of why we bought was to take a risk off the table. We were four people who had mostly just met, with no track record of working together and no evidence yet about how much we could trust each other's judgment, and putting the problem in a vendor's hands meant getting it right was their job rather than ours.
It is not clear that trade paid off. We had less control over translation quality than we wanted, and documentation vague enough that we found the footguns by stepping on them. We still had to build our own automation to pull the translations back into our repositories, so the pipeline work never actually went away. And it was expensive. The side we built ourselves gave us more control for less money.
Choosing again today we would probably build it, and coding agents are part of why: the cost of writing the unglamorous parts has dropped. It would be cheaper, it would put static strings and dynamic content behind a single pane of glass instead of two, and it would do exactly what we need without the features we do not.
The generalizable lesson is that buying is not the same as de-risking. A vendor carries its own risks, and they are the harder ones to see at the moment you are choosing: quality you cannot tune, documentation you cannot fix, integration work that lands on you regardless. Now that building bespoke software is far cheaper than it used to be, the bar a vendor has to clear is much higher than it was. Buying a tool moved the work, not the responsibility, and the output was still ours to check.
What's Next
Human review. Corrections need somewhere to be written and a guarantee the next run will respect them, and beyond that they need a front door: a French speaker in our office who spots an awkward translation should not need database access to fix it. That belongs behind an internal tool.
Then a measure of translation quality over time, so a decline is something we see rather than something a guest reports.
We also want to evaluate bringing frontend translation in house, since our backend pipeline has been better on both quality and cost than we expected.
Beyond that, the product roadmap. Currency next, so prices and payment feel locally native rather than converted. Then compliance, which will be its own kind of fun. Then the market-specific features that only matter once we are actually operating in a market.
Language was the foundation. Now we get to build on it.